Files
sub-store/internal/middleware/security.go
T

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
}