backup: pre-hardening baseline
This commit is contained in:
commit
9e4c612dcb
57 changed files with 10393 additions and 0 deletions
290
backend/internal/config/file/validate.go
Normal file
290
backend/internal/config/file/validate.go
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ValidationError with line info
|
||||
type ValidationError struct {
|
||||
Message string `json:"message"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Column int `json:"column,omitempty"`
|
||||
}
|
||||
|
||||
func (e ValidationError) Error() string {
|
||||
if e.Line > 0 {
|
||||
return fmt.Sprintf("line %d col %d: %s", e.Line, e.Column, e.Message)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// ValidateContent checks YAML content before write.
|
||||
// Rejects empty, dangerous, or structurally invalid configs.
|
||||
// Allows only dynamic config top-level keys: http, tcp, udp, tls.
|
||||
// Additionally validates nested router/service/middleware/TLS structure to match Traefik v3.7 dynamic schema.
|
||||
func ValidateContent(filename, content string) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" {
|
||||
errs = append(errs, ValidationError{Message: "content must not be empty"})
|
||||
return errs
|
||||
}
|
||||
|
||||
if err := ValidateFilename(filename); err != nil {
|
||||
errs = append(errs, ValidationError{Message: err.Error()})
|
||||
return errs
|
||||
}
|
||||
|
||||
// TOML files: only syntax check via extension, full schema validated as YAML for MVP.
|
||||
// If filename is .toml, require non-empty and no traversal already checked; skip YAML schema for now.
|
||||
isTOML := strings.HasSuffix(strings.ToLower(filename), ".toml")
|
||||
if isTOML {
|
||||
if len(content) > 1*1024*1024 {
|
||||
errs = append(errs, ValidationError{Message: "content too large (>1MB)"})
|
||||
}
|
||||
// Basic TOML sanity: must contain '=' and not be pure YAML mapping without equals?
|
||||
// Accept any non-empty TOML for MVP, but reject obvious YAML-only constructs without '='
|
||||
return errs
|
||||
}
|
||||
|
||||
// YAML syntax check with line extraction
|
||||
var raw map[string]interface{}
|
||||
var node yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(content), &raw); err != nil {
|
||||
// Try to extract line/col via yaml.Node
|
||||
if err2 := yaml.Unmarshal([]byte(content), &node); err2 == nil {
|
||||
// fallthrough handled by raw error
|
||||
}
|
||||
if ye, ok := err.(*yaml.TypeError); ok {
|
||||
for _, msg := range ye.Errors {
|
||||
errs = append(errs, ValidationError{Message: msg})
|
||||
}
|
||||
} else {
|
||||
// Parse line from error string like "yaml: line 3: ..."
|
||||
msg := err.Error()
|
||||
line, col := parseYAMLLineCol(msg)
|
||||
errs = append(errs, ValidationError{Message: msg, Line: line, Column: col})
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
if raw == nil {
|
||||
errs = append(errs, ValidationError{Message: "YAML must be a mapping"})
|
||||
return errs
|
||||
}
|
||||
|
||||
allowedTop := map[string]bool{"http": true, "tcp": true, "udp": true, "tls": true}
|
||||
hasAllowed := false
|
||||
for k := range raw {
|
||||
if allowedTop[k] {
|
||||
hasAllowed = true
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("unknown top-level key %q: allowed keys are http, tcp, udp, tls", k)})
|
||||
}
|
||||
}
|
||||
if !hasAllowed {
|
||||
errs = append(errs, ValidationError{Message: "config must contain at least one of: http, tcp, udp, tls"})
|
||||
}
|
||||
|
||||
if len(content) > 1*1024*1024 {
|
||||
errs = append(errs, ValidationError{Message: "content too large (>1MB)"})
|
||||
}
|
||||
|
||||
// Deep schema validation matching Traefik v3.7 dynamic config
|
||||
errs = append(errs, validateHTTPBlock(raw["http"])...)
|
||||
errs = append(errs, validateTCPBlock(raw["tcp"])...)
|
||||
errs = append(errs, validateUDPBlock(raw["udp"])...)
|
||||
errs = append(errs, validateTLSBlock(raw["tls"])...)
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func parseYAMLLineCol(msg string) (int, int) {
|
||||
// Example: "yaml: line 3: did not find expected ','"
|
||||
var line, col int
|
||||
_, _ = fmt.Sscanf(msg, "yaml: line %d: ", &line)
|
||||
// Column rarely present in gopkg.in/yaml.v3 errors; leave 0
|
||||
return line, col
|
||||
}
|
||||
|
||||
func validateHTTPBlock(raw interface{}) []ValidationError {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{Message: "http must be a mapping"}}
|
||||
}
|
||||
var errs []ValidationError
|
||||
allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true, "models": true}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http: unknown key %q (allowed: routers, services, middlewares, serversTransports, models)", k)})
|
||||
}
|
||||
}
|
||||
if routers, ok := m["routers"]; ok {
|
||||
if rm, ok := routers.(map[string]interface{}); ok {
|
||||
for name, rv := range rm {
|
||||
if r, ok := rv.(map[string]interface{}); ok {
|
||||
if _, hasRule := r["rule"]; !hasRule {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'rule'", name)})
|
||||
}
|
||||
if _, hasService := r["service"]; !hasService {
|
||||
// service is required unless it's a middleware chain? For MVP require service
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'service'", name)})
|
||||
}
|
||||
if rule, ok := r["rule"].(string); ok && strings.TrimSpace(rule) == "" {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: rule must not be empty", name)})
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: must be a mapping", name)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: "http.routers must be a mapping"})
|
||||
}
|
||||
}
|
||||
if services, ok := m["services"]; ok {
|
||||
if sm, ok := services.(map[string]interface{}); ok {
|
||||
for name, sv := range sm {
|
||||
if s, ok := sv.(map[string]interface{}); ok {
|
||||
hasLB := s["loadBalancer"] != nil
|
||||
hasWeighted := s["weighted"] != nil
|
||||
hasMirroring := s["mirroring"] != nil
|
||||
hasFailover := s["failover"] != nil
|
||||
if !hasLB && !hasWeighted && !hasMirroring && !hasFailover {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must define one of loadBalancer, weighted, mirroring, failover", name)})
|
||||
}
|
||||
if lb, ok := s["loadBalancer"]; ok && lb != nil {
|
||||
if lbm, ok := lb.(map[string]interface{}); ok {
|
||||
if servers, ok := lbm["servers"]; ok {
|
||||
if arr, ok := servers.([]interface{}); ok {
|
||||
if len(arr) == 0 {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers: must not be empty", name)})
|
||||
}
|
||||
for i, srv := range arr {
|
||||
if sm, ok := srv.(map[string]interface{}); ok {
|
||||
if _, hasURL := sm["url"]; !hasURL {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers[%d]: missing 'url'", name, i)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must be a mapping", name)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: "http.services must be a mapping"})
|
||||
}
|
||||
}
|
||||
if middlewares, ok := m["middlewares"]; ok {
|
||||
if mm, ok := middlewares.(map[string]interface{}); ok {
|
||||
for name, mv := range mm {
|
||||
if _, ok := mv.(map[string]interface{}); !ok {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.middlewares.%q: must be a mapping", name)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: "http.middlewares must be a mapping"})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateTCPBlock(raw interface{}) []ValidationError {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{Message: "tcp must be a mapping"}}
|
||||
}
|
||||
var errs []ValidationError
|
||||
allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp: unknown key %q", k)})
|
||||
}
|
||||
}
|
||||
if routers, ok := m["routers"]; ok {
|
||||
if rm, ok := routers.(map[string]interface{}); ok {
|
||||
for name, rv := range rm {
|
||||
if r, ok := rv.(map[string]interface{}); ok {
|
||||
if _, hasRule := r["rule"]; !hasRule {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'rule'", name)})
|
||||
}
|
||||
if _, hasService := r["service"]; !hasService {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'service'", name)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateUDPBlock(raw interface{}) []ValidationError {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{Message: "udp must be a mapping"}}
|
||||
}
|
||||
var errs []ValidationError
|
||||
allowed := map[string]bool{"routers": true, "services": true}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("udp: unknown key %q", k)})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateTLSBlock(raw interface{}) []ValidationError {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{Message: "tls must be a mapping"}}
|
||||
}
|
||||
var errs []ValidationError
|
||||
allowed := map[string]bool{"certificates": true, "options": true, "stores": true}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("tls: unknown key %q", k)})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// ValidateFilename ensures filename is safe and within dynamic dir
|
||||
func ValidateFilename(filename string) error {
|
||||
if filename == "" {
|
||||
return fmt.Errorf("filename must not be empty")
|
||||
}
|
||||
if strings.Contains(filename, "..") {
|
||||
return fmt.Errorf("filename must not contain '..'")
|
||||
}
|
||||
if strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
|
||||
return fmt.Errorf("filename must not contain path separators — use a single file name")
|
||||
}
|
||||
// Must end with allowed extension
|
||||
lower := strings.ToLower(filename)
|
||||
if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) {
|
||||
return fmt.Errorf("filename must end with .yml, .yaml, or .toml")
|
||||
}
|
||||
if len(filename) > 255 {
|
||||
return fmt.Errorf("filename too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue