backup: pre-hardening baseline

This commit is contained in:
backup 2026-09-02 11:20:31 -05:00
commit 9e4c612dcb
57 changed files with 10393 additions and 0 deletions

View 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
}