109 lines
3.1 KiB
Go
109 lines
3.1 KiB
Go
package middleware
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
)
|
|
|
|
// SecurityHeaders sets CSP and other security response headers.
|
|
// Per review-resolution #27: CSP tightened to script-src 'self' (no unsafe-eval).
|
|
func SecurityHeaders() fiber.Handler {
|
|
return func(c fiber.Ctx) error {
|
|
c.Set("Content-Security-Policy",
|
|
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; "+
|
|
"script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "+
|
|
"font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'")
|
|
c.Set("Referrer-Policy", "no-referrer")
|
|
c.Set("X-Content-Type-Options", "nosniff")
|
|
c.Set("X-Frame-Options", "DENY")
|
|
c.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// BodyLimit middleware.
|
|
func BodyLimit(limit int) fiber.Handler {
|
|
return func(c fiber.Ctx) error {
|
|
if len(c.Body()) > limit {
|
|
return c.Status(413).JSON(fiber.Map{
|
|
"status": "failed",
|
|
"error": fiber.Map{"code": 413, "message": "Request body is too large"},
|
|
})
|
|
}
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// DownloadHostIsolation ensures download hosts only serve /download/ paths.
|
|
func DownloadHostIsolation(downloadHosts []string) fiber.Handler {
|
|
hostSet := make(map[string]bool, len(downloadHosts))
|
|
for _, h := range downloadHosts {
|
|
h = strings.ToLower(strings.TrimSpace(h))
|
|
if h != "" {
|
|
hostSet[h] = true
|
|
}
|
|
}
|
|
return func(c fiber.Ctx) error {
|
|
if len(hostSet) == 0 {
|
|
return c.Next()
|
|
}
|
|
host := strings.ToLower(c.Hostname())
|
|
if hostSet[host] && !strings.HasPrefix(c.Path(), "/download/") {
|
|
return c.Status(404).SendString("Not Found")
|
|
}
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// crlfRe matches CR and LF characters for injection prevention.
|
|
var crlfRe = regexp.MustCompile(`[\r\n]`)
|
|
|
|
// SetSafeResponseHeader sets a header value only if it doesn't contain CRLF.
|
|
// Per review-resolution #25: CRLF injection protection.
|
|
func SetSafeResponseHeader(c fiber.Ctx, name, value string) {
|
|
if value != "" && !crlfRe.MatchString(value) {
|
|
c.Set(name, value)
|
|
}
|
|
}
|
|
|
|
// SafeContentDisposition sanitizes a filename for Content-Disposition.
|
|
// Per review-resolution #25: strip CRLF and control chars, escape special chars.
|
|
func SafeContentDisposition(value string) string {
|
|
if value == "" || crlfRe.MatchString(value) {
|
|
return ""
|
|
}
|
|
// Extract filename from the header value
|
|
filename := extractFilename(value)
|
|
if filename == "" {
|
|
return ""
|
|
}
|
|
// Sanitize: keep alphanumerics, dots, hyphens, parens, CJK, spaces
|
|
safe := sanitizeFilename(filename)
|
|
if safe == "" {
|
|
return ""
|
|
}
|
|
return `attachment; filename="` + safe + `"`
|
|
}
|
|
|
|
var filenameRe = regexp.MustCompile(`filename\*?=(?:UTF-8''|")?([^";]+)`)
|
|
|
|
func extractFilename(value string) string {
|
|
matches := filenameRe.FindStringSubmatch(value)
|
|
if len(matches) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(matches[1])
|
|
}
|
|
|
|
var sanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9.()\- \x{4e00}-\x{9fff}]`)
|
|
|
|
func sanitizeFilename(name string) string {
|
|
safe := sanitizeRe.ReplaceAllString(name, "_")
|
|
if len(safe) > 120 {
|
|
safe = safe[:120]
|
|
}
|
|
return safe
|
|
}
|