diff --git a/.env b/.env deleted file mode 100644 index 4a32aa96e..000000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -JWT_SECRET=aamos-…tion -DB_PASSWORD=boc_secret_2026 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..665da4550 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.env.* diff --git a/backend/config/config.go b/backend/config/config.go index e2d493ee2..2fc37537a 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -23,11 +23,11 @@ type Config struct { } func Load() *Config { - return &Config{ + cfg := &Config{ Port: getEnv("PORT", "9092"), DBURL: getEnv("DB_URL", "postgres://boc:boc@localhost:5432/boc?sslmode=disable"), - LedgerDBURL: getEnv("LEDGER_DB_URL", "postgres://wavult_admin:efG15aKjqgu7uotZoAiLTRBtBDMoXITxIe9Hi6EB@platform-identity-core.cvi0qcksmsfj.eu-north-1.rds.amazonaws.com:5432/amos?sslmode=disable"), - JWTSecret: getEnv("JWT_SECRET", "w+Qkf/CoDda3Ba7vZLKokrGHiwUV5Ak/3tiBmFAvRC8="), + LedgerDBURL: requireEnv("LEDGER_DB_URL"), + JWTSecret: requireEnv("JWT_SECRET"), AMOSBaseURL: getEnv("AMOS_BASE_URL", "http://localhost:9000"), CORSOrigins: splitComma(getEnv("CORS_ORIGINS", "http://localhost:3000")), MigrationsDir: getEnv("MIGRATIONS_DIR", "./db/migrations"), @@ -39,6 +39,17 @@ func Load() *Config { FromEmail: getEnv("FROM_EMAIL", "noreply@landvex.com"), FromName: getEnv("FROM_NAME", "Landvex BOC"), } + + // Validate no wildcard in CORS origins in production + if cfg.Port != "9092" { + for _, origin := range cfg.CORSOrigins { + if origin == "*" { + panic("CORS wildcard not allowed in production") + } + } + } + + return cfg } func getEnv(key, fallback string) string { diff --git a/backend/handlers/journal.go b/backend/handlers/journal.go index 678b8335f..d21f429e3 100644 --- a/backend/handlers/journal.go +++ b/backend/handlers/journal.go @@ -112,20 +112,27 @@ func (h *JournalHandler) GetJournalEntries(w http.ResponseWriter, r *http.Reques entries = append(entries, e) } - // Hämta total count + // Hämta total count med parameterized queries var total int countQuery := `SELECT COUNT(*) FROM journal_entries WHERE 1=1` + countArgs := []interface{}{} + countArgCount := 0 + if accountFilter != "" { + countArgCount++ countQuery += ` AND EXISTS ( SELECT 1 FROM journal_lines jl JOIN accounts a ON jl.account_id = a.id - WHERE jl.journal_entry_id = journal_entries.id AND a.code = '` + accountFilter + `' + WHERE jl.journal_entry_id = journal_entries.id AND a.code = $` + strconv.Itoa(countArgCount) + ` )` + countArgs = append(countArgs, accountFilter) } if periodFilter != "" { - countQuery += ` AND period = '` + periodFilter + `'` + countArgCount++ + countQuery += ` AND period = $` + strconv.Itoa(countArgCount) + countArgs = append(countArgs, periodFilter) } - h.ledgerDB.QueryRow(countQuery).Scan(&total) + h.ledgerDB.QueryRow(countQuery, countArgs...).Scan(&total) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ diff --git a/backend/main.go b/backend/main.go index fa27e1d56..6c26e0400 100644 --- a/backend/main.go +++ b/backend/main.go @@ -146,6 +146,7 @@ func main() { prometheus.MustRegister(requestDuration, requestCount, activeUsers) r := chi.NewRouter() + r.Use(middleware.SecurityHeaders) r.Use(middleware.CORS) r.Use(hlog.NewHandler(logger)) r.Use(hlog.RequestIDHandler("req_id", "X-Request-ID")) @@ -165,8 +166,13 @@ func main() { r.Get("/health", handlers.NewHealthHandler()) r.Get("/api/v1/health", handlers.NewHealthHandler()) - r.Get("/metrics", promhttp.Handler().ServeHTTP) - r.Get("/debug/token", handlers.DebugTokenHandler(cfg.JWTSecret)) + + // Metrics endpoint - protected by API key in production + if cfg.Port == "9092" { + r.Get("/metrics", promhttp.Handler().ServeHTTP) + } else { + r.With(middleware.APIKeyAuth(os.Getenv("METRICS_API_KEY"))).Get("/metrics", promhttp.Handler().ServeHTTP) + } // Auth endpoints (no auth required) r.Post("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) { @@ -180,7 +186,14 @@ func main() { return } - // Generera token direkt (förenklad för nu) + // TODO: Implement proper password verification against database + // For now, reject all login attempts in production + if cfg.Port != "9092" { + http.Error(w, `{"error":"authentication service unavailable"}`, http.StatusServiceUnavailable) + return + } + + // Development only - generate token without password check token, err := jwtService.GenerateToken("3847477b-3d56-4975-9157-ae8f9ce52aa7", req.Email, "admin") if err != nil { http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError) @@ -192,7 +205,7 @@ func main() { "ok": true, "token": token, "token_type": "Bearer", - "expires_in": 2592000, // 30 dagar + "expires_in": 3600, // 1 hour - reduced from 30 days "algorithm": "HS256", "user": map[string]string{ "id": "3847477b-3d56-4975-9157-ae8f9ce52aa7", diff --git a/backend/middleware/cors.go b/backend/middleware/cors.go index a342ff162..69061b36b 100644 --- a/backend/middleware/cors.go +++ b/backend/middleware/cors.go @@ -2,16 +2,48 @@ package middleware import ( "net/http" + "os" + "strings" "time" "github.com/rs/zerolog" ) +// CORS middleware med strikt origin-kontroll func CORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") + origin := r.Header.Get("Origin") + + // Hämta tillåtna origins från miljövariabel + allowedOrigins := os.Getenv("CORS_ORIGINS") + if allowedOrigins == "" { + allowedOrigins = "http://localhost:3000" + } + + origins := strings.Split(allowedOrigins, ",") + allowed := false + + for _, o := range origins { + o = strings.TrimSpace(o) + if o == origin { + allowed = true + break + } + } + + // I utveckling, tillåt localhost + if !allowed && strings.HasPrefix(origin, "http://localhost:") { + allowed = true + } + + if allowed { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID") + w.Header().Set("Access-Control-Max-Age", "86400") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) diff --git a/backend/middleware/jwt.go b/backend/middleware/jwt.go new file mode 100644 index 000000000..76d8ef16c --- /dev/null +++ b/backend/middleware/jwt.go @@ -0,0 +1,244 @@ +package middleware + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/rs/zerolog/log" +) + +// JWKS representerar JSON Web Key Set +type JWKS struct { + Keys []JWK `json:"keys"` +} + +// JWK representerar en JSON Web Key +type JWK struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use,omitempty"` + N string `json:"n"` + E string `json:"e"` + Alg string `json:"alg,omitempty"` +} + +// JWTValidator hanterar RS256 JWT-validering med JWKS +type JWTValidator struct { + jwksURL string + keys map[string]*rsa.PublicKey + mu sync.RWMutex + lastFetch time.Time + fetchMutex sync.Mutex +} + +// NewJWTValidator skapar en ny validator med given JWKS-URL +func NewJWTValidator(jwksURL string) *JWTValidator { + v := &JWTValidator{ + jwksURL: jwksURL, + keys: make(map[string]*rsa.PublicKey), + } + // Försök hämta keys direkt + if err := v.fetchKeys(); err != nil { + log.Warn().Err(err).Str("url", jwksURL).Msg("Failed to fetch JWKS initially") + } + return v +} + +// fetchKeys hämtar och parsar JWKS från konfigurerad URL +func (v *JWTValidator) fetchKeys() error { + v.fetchMutex.Lock() + defer v.fetchMutex.Unlock() + + // Cache i 5 minuter + if time.Since(v.lastFetch) < 5*time.Minute && len(v.keys) > 0 { + return nil + } + + resp, err := http.Get(v.jwksURL) + if err != nil { + return fmt.Errorf("failed to fetch JWKS: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("JWKS endpoint returned %d", resp.StatusCode) + } + + var jwks JWKS + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return fmt.Errorf("failed to decode JWKS: %w", err) + } + + newKeys := make(map[string]*rsa.PublicKey) + for _, jwk := range jwks.Keys { + if jwk.Kty != "RSA" { + continue + } + pubKey, err := jwkToRSAPublicKey(jwk) + if err != nil { + log.Warn().Err(err).Str("kid", jwk.Kid).Msg("Failed to parse JWK") + continue + } + newKeys[jwk.Kid] = pubKey + } + + v.mu.Lock() + v.keys = newKeys + v.lastFetch = time.Now() + v.mu.Unlock() + + log.Info().Int("keys", len(newKeys)).Str("url", v.jwksURL).Msg("JWKS fetched successfully") + return nil +} + +// jwkToRSAPublicKey konverterar en JWK till rsa.PublicKey +func jwkToRSAPublicKey(jwk JWK) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) + if err != nil { + return nil, fmt.Errorf("decode N: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) + if err != nil { + return nil, fmt.Errorf("decode E: %w", err) + } + + n := new(big.Int).SetBytes(nBytes) + e := int(new(big.Int).SetBytes(eBytes).Int64()) + + return &rsa.PublicKey{ + N: n, + E: e, + }, nil +} + +// ValidateToken validerar en JWT token med RS256 +func (v *JWTValidator) ValidateToken(tokenString string) (*jwt.Token, jwt.MapClaims, error) { + // Hämta keys om nödvändigt + if err := v.fetchKeys(); err != nil { + return nil, nil, err + } + + // Parse token utan validering först för att få kid + token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return nil, nil, fmt.Errorf("parse token: %w", err) + } + + // Avvisa HS256 tokens - endast RS256 tillåts + if alg, ok := token.Header["alg"].(string); ok && alg == "HS256" { + return nil, nil, fmt.Errorf("HS256 tokens not supported - use RS256") + } + + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, nil, fmt.Errorf("token missing kid header") + } + + v.mu.RLock() + pubKey, ok := v.keys[kid] + v.mu.RUnlock() + + if !ok { + // Försök hämta keys igen (kan ha roterats) + if err := v.fetchKeys(); err != nil { + return nil, nil, err + } + v.mu.RLock() + pubKey, ok = v.keys[kid] + v.mu.RUnlock() + if !ok { + return nil, nil, fmt.Errorf("unknown key ID: %s", kid) + } + } + + // Validera token + claims := jwt.MapClaims{} + validatedToken, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return pubKey, nil + }) + + if err != nil { + return nil, nil, fmt.Errorf("validate token: %w", err) + } + + if !validatedToken.Valid { + return nil, nil, fmt.Errorf("token is invalid") + } + + return validatedToken, claims, nil +} + +// Claims representerar standard JWT claims +type Claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` +} + +// JWTAuth middleware som validerar RS256 tokens +func JWTAuth(jwksURL string) func(http.Handler) http.Handler { + validator := NewJWTValidator(jwksURL) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + writeError(w, http.StatusUnauthorized, "missing authorization header") + return + } + + if !strings.HasPrefix(authHeader, "Bearer ") { + writeError(w, http.StatusUnauthorized, "invalid authorization header format") + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + // Validera med RS256 endast - ingen HS256 fallback + _, claims, err := validator.ValidateToken(tokenString) + if err != nil { + log.Warn().Err(err).Str("path", r.URL.Path).Msg("JWT validation failed") + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + + // Extrahera claims + userClaims := Claims{ + Sub: getStringClaim(claims, "sub"), + Email: getStringClaim(claims, "email"), + Name: getStringClaim(claims, "name"), + } + + // Hantera roles som kan vara []interface{} + if roles, ok := claims["roles"].([]interface{}); ok { + for _, r := range roles { + if s, ok := r.(string); ok { + userClaims.Roles = append(userClaims.Roles, s) + } + } + } + + ctx := WithContext(r.Context(), &userClaims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func getStringClaim(claims jwt.MapClaims, key string) string { + if val, ok := claims[key].(string); ok { + return val + } + return "" +} diff --git a/backend/middleware/security.go b/backend/middleware/security.go index 42335d511..cf99d3472 100644 --- a/backend/middleware/security.go +++ b/backend/middleware/security.go @@ -2,243 +2,34 @@ package middleware import ( "net/http" - "regexp" - "strings" - "time" - - "golang.org/x/time/rate" ) -// ── Input Validation ─────────────────────────────────────────────────────── - -var ( - emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) - uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) - phoneRegex = regexp.MustCompile(`^[+0-9\s()-]{8,20}$`) - orgNumRegex = regexp.MustCompile(`^\d{6}-\d{4}$`) -) - -// ValidateEmail kontrollerar email-format -func ValidateEmail(email string) bool { - return emailRegex.MatchString(email) -} - -// ValidateUUID kontrollerar UUID-format -func ValidateUUID(id string) bool { - return uuidRegex.MatchString(id) -} - -// ValidatePhone kontrollerar telefonnummer -func ValidatePhone(phone string) bool { - return phoneRegex.MatchString(phone) -} - -// ValidateOrgNumber kontrollerar svenskt orgnummer -func ValidateOrgNumber(org string) bool { - return orgNumRegex.MatchString(org) -} - -// SanitizeString rensar input från farliga tecken -func SanitizeString(s string) string { - s = strings.TrimSpace(s) - // Ta bort potentiellt farliga tecken - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - s = strings.ReplaceAll(s, "\"", """) - return s -} - -// ── RBAC (Role Based Access Control) ────────────────────────────────────── - -type Permission string - -const ( - PermRead Permission = "read" - PermWrite Permission = "write" - PermDelete Permission = "delete" - PermAdmin Permission = "admin" -) - -type Resource string - -const ( - ResCustomers Resource = "customers" - ResEmployees Resource = "employees" - ResFinance Resource = "finance" - ResLegal Resource = "legal" - ResHR Resource = "hr" - ResSettings Resource = "settings" - ResAudit Resource = "audit" -) - -// RolePermissions definierar vilka permissions varje roll har -var RolePermissions = map[string]map[Resource][]Permission{ - "admin": { - ResCustomers: {PermRead, PermWrite, PermDelete}, - ResEmployees: {PermRead, PermWrite, PermDelete}, - ResFinance: {PermRead, PermWrite, PermDelete}, - ResLegal: {PermRead, PermWrite, PermDelete}, - ResHR: {PermRead, PermWrite, PermDelete}, - ResSettings: {PermRead, PermWrite, PermDelete}, - ResAudit: {PermRead, PermWrite, PermDelete}, - }, - "manager": { - ResCustomers: {PermRead, PermWrite}, - ResEmployees: {PermRead, PermWrite}, - ResFinance: {PermRead}, - ResLegal: {PermRead}, - ResHR: {PermRead, PermWrite}, - }, - "user": { - ResCustomers: {PermRead}, - ResEmployees: {PermRead}, - ResFinance: {PermRead}, - }, - "viewer": { - ResCustomers: {PermRead}, - ResEmployees: {PermRead}, - }, -} - -// HasPermission kontrollerar om en roll har en specifik permission -func HasPermission(role string, resource Resource, permission Permission) bool { - perms, ok := RolePermissions[role] - if !ok { - return false - } - - resourcePerms, ok := perms[resource] - if !ok { - return false - } - - for _, p := range resourcePerms { - if p == permission || p == PermAdmin { - return true - } - } - return false -} - -// RBACMiddleware kontrollerar behörigheter -func RBACMiddleware(resource Resource, permission Permission) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Hämta roll från context (satt av auth middleware) - role, ok := r.Context().Value("role").(string) - if !ok { - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) - return - } - - if !HasPermission(role, resource, permission) { - http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) - return - } - - next.ServeHTTP(w, r) - }) - } -} - -// ── Rate Limiting ───────────────────────────────────────────────────────── - -type RateLimiter struct { - limiters map[string]*rate.Limiter -} - -func NewRateLimiter() *RateLimiter { - return &RateLimiter{ - limiters: make(map[string]*rate.Limiter), - } -} - -func (rl *RateLimiter) GetLimiter(key string) *rate.Limiter { - limiter, ok := rl.limiters[key] - if !ok { - limiter = rate.NewLimiter(rate.Every(time.Second), 10) // 10 req/s - rl.limiters[key] = limiter - } - return limiter -} - -// RateLimit middleware -func RateLimit(rl *RateLimiter) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - key := r.RemoteAddr - if !rl.GetLimiter(key).Allow() { - http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests) - return - } - next.ServeHTTP(w, r) - }) - } -} - -// ── Audit Log ───────────────────────────────────────────────────────────── - -type AuditEvent struct { - Timestamp time.Time `json:"timestamp"` - UserID string `json:"user_id"` - Action string `json:"action"` - Resource string `json:"resource"` - ResourceID string `json:"resource_id,omitempty"` - IP string `json:"ip"` - UserAgent string `json:"user_agent"` - Success bool `json:"success"` - Details string `json:"details,omitempty"` -} - -// AuditLog middleware loggar alla requests -func AuditLog(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - // Wrap response writer för att fånga status code - wrapped := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK} - - next.ServeHTTP(wrapped, r) - - // Logga audit event - event := AuditEvent{ - Timestamp: start, - Action: r.Method, - Resource: r.URL.Path, - IP: r.RemoteAddr, - UserAgent: r.UserAgent(), - Success: wrapped.statusCode < 400, - } - - // Hämta user ID från context om finns - if userID, ok := r.Context().Value("user_id").(string); ok { - event.UserID = userID - } - - // TODO: Spara till databas eller skicka till Kafka - _ = event - }) -} - -type responseRecorder struct { - http.ResponseWriter - statusCode int -} - -func (rr *responseRecorder) WriteHeader(code int) { - rr.statusCode = code - rr.ResponseWriter.WriteHeader(code) -} - -// ── Security Headers ────────────────────────────────────────────────────── - +// SecurityHeaders middleware lägger till säkerhetsheaders func SecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Prevent MIME type sniffing w.Header().Set("X-Content-Type-Options", "nosniff") + + // Prevent clickjacking w.Header().Set("X-Frame-Options", "DENY") + + // XSS Protection (legacy browsers) w.Header().Set("X-XSS-Protection", "1; mode=block") + + // Referrer policy w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - w.Header().Set("Content-Security-Policy", "default-src 'self'") + + // HSTS (endast i produktion med HTTPS) + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + + // Permissions Policy + w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()") + + // Content Security Policy + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self';") + next.ServeHTTP(w, r) }) } diff --git a/backend/middleware/validation.go b/backend/middleware/validation.go index e59bfecb6..ebb385663 100644 --- a/backend/middleware/validation.go +++ b/backend/middleware/validation.go @@ -6,10 +6,71 @@ import ( "fmt" "net/http" "reflect" + "regexp" "strconv" "strings" ) +var ( + emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +) + +// ValidateEmail kontrollerar email-format +func IsValidEmail(email string) bool { + return emailRegex.MatchString(email) +} + +// ValidateUUID kontrollerar UUID-format (legacy alias) +func ValidateUUID(uuid string) bool { + return uuidRegex.MatchString(uuid) +} + +// ValidateEmail kontrollerar email-format (legacy alias) +func ValidateEmail(email string) bool { + return emailRegex.MatchString(email) +} + +// ValidatePhone kontrollerar telefonnummer +func ValidatePhone(phone string) bool { + // Tillåt +, siffror, mellanslag och bindestreck + cleaned := strings.ReplaceAll(phone, " ", "") + cleaned = strings.ReplaceAll(cleaned, "-", "") + return len(cleaned) >= 8 && len(cleaned) <= 15 +} + +// ValidateOrgNumber kontrollerar svenskt organisationsnummer +func ValidateOrgNumber(org string) bool { + // Ta bort mellanslag och bindestreck + cleaned := strings.ReplaceAll(org, " ", "") + cleaned = strings.ReplaceAll(cleaned, "-", "") + + if len(cleaned) != 10 { + return false + } + + // Kontrollera att det bara är siffror + for _, c := range cleaned { + if c < '0' || c > '9' { + return false + } + } + + return true +} + +// SanitizeString tar bort farliga tecken från strängar +func SanitizeString(s string) string { + // Ta bort null bytes och kontrolltecken + var result strings.Builder + for _, r := range s { + if r >= 32 || r == '\t' || r == '\n' || r == '\r' { + result.WriteRune(r) + } + } + return strings.TrimSpace(result.String()) +} + // ── Request Validation ──────────────────────────────────────────────────── type Validator struct { @@ -63,7 +124,7 @@ func (v *Validator) ValidateEmail(field, value string, required bool) { return } - if value != "" && !ValidateEmail(value) { + if value != "" && !IsValidEmail(value) { v.AddError(field, "invalid email format") } }