Security
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/backend/references/security.md |
| Description | Not specified |
Source Content
Security
Security is enforced at the boundary — the handler validates every input before it reaches a service — and then again in depth behind it: parameterized queries in the repository, least-privilege tokens in the service, safe defaults in the framework. It is not a checklist run once before ship; it is a property of every layer, verified at each one, so a single missed check anywhere else still fails closed.
Checklist by category
| Severity | Category | Requirement | Why it matters |
|---|---|---|---|
| Critical | Input validation | Validate at the handler with the OpenAPI-generated types (references/openapi.md); reject unknown fields and out-of-range values before calling the service | The service and repository trust their callers; a handler that lets malformed input through poisons every layer behind it |
| Critical | SQL injection | Every query is parameterized ($1, $2, …) via pgx/GORM; never fmt.Sprintf a value into SQL | String-built SQL is the single most common path to full data exfiltration |
| Critical | Cryptography | Use vetted primitives only — crypto/*, golang.org/x/crypto/* — never a hand-rolled cipher or hash | Custom crypto fails in ways that don’t show up until an attacker finds them |
| High | Web: CSRF | State-changing requests (POST/PUT/PATCH/DELETE) from a browser session require a CSRF token or SameSite=Strict/Lax cookies | Without it, any site the user has open can trigger authenticated requests on their behalf |
| High | Web: CORS | Access-Control-Allow-Origin is an explicit allowlist, never * alongside credentialed requests | A wildcard origin with credentials lets any site read authenticated responses |
| Critical | Authentication | Verify token signature and expiry on every request; never trust a client-supplied claim without verification | An unverified token is equivalent to no authentication at all |
| Critical | Authorization | Check the resource belongs to the caller (row-level check), not just that the caller is logged in | Authentication proves identity; only authorization proves the caller may touch this invoice |
| High | Error handling | Map to a generic Problem Details title at the handler; log the full error server-side only (references/errors.md) | Internal error text (SQL, file paths, stack frames) hands an attacker a map of the system |
| High | Dependencies | Run govulncheck in CI; pin versions in go.sum; review before bumping a major version | Most exploited vulnerabilities are in dependencies, not first-party code |
| Medium | Security headers | Set Content-Security-Policy, X-Content-Type-Options: nosniff, Strict-Transport-Security | Browser-enforced defenses that cost one middleware and block whole exploit classes |
| High | Rate limiting | Apply per-IP and per-account limits on auth and write endpoints | Without it, credential stuffing and abusive writes are unbounded |
| High | Concurrency (TOCTOU) | Check-then-act on shared state (balance checks, stock decrements) happens inside a DB transaction with row locking (references/database.md), not as two separate calls | A gap between the check and the act is a window an attacker can race |
Filesystem: path traversal and zip-slip
os.Root as the primary defense
Any handler that takes a user-supplied path — a file download, an upload destination, an archive extraction target — opens an os.Root and does every filesystem operation through it. os.Root rejects .. traversal and symlink escapes at the OS boundary, so a single missed filepath.Clean elsewhere in the call chain does not become a traversal bug.
func (h *InvoiceHandler) DownloadAttachment(c *fiber.Ctx) error { invoiceID := domain.InvoiceID(c.Params("id")) filename := c.Params("filename") // user-supplied — never trust as a path directly
root, err := os.OpenRoot(h.attachmentsDir) if err != nil { return fmt.Errorf("open attachments root: %w", err) } defer root.Close()
f, err := root.Open(filepath.Join(string(invoiceID), filename)) if err != nil { return problem(c, fiber.StatusNotFound, "attachment not found") } defer f.Close()
return c.SendStream(f)}root.Open resolves the joined path relative to h.attachmentsDir and fails if it would escape — ../../etc/passwd never reaches the real filesystem, regardless of how it is spelled.
Zip-slip on archive extraction
An archive entry’s own path is attacker-controlled the same way a URL param is. Validate every entry against the destination root before writing it — even a validated entry still writes through os.Root, so the check and the containment are both enforced, not just one.
func extractInvoiceArchive(zr *zip.Reader, destRoot *os.Root) error { for _, entry := range zr.File { clean := filepath.Clean(entry.Name) if clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(clean) { return fmt.Errorf("zip-slip: entry %q escapes destination root", entry.Name) }
src, err := entry.Open() if err != nil { return fmt.Errorf("open archive entry %q: %w", entry.Name, err) } defer src.Close()
dst, err := destRoot.Create(clean) // second line of defense: os.Root rejects escapes independently if err != nil { return fmt.Errorf("create %q under dest root: %w", clean, err) } defer dst.Close()
if _, err := io.Copy(dst, src); err != nil { return fmt.Errorf("extract %q: %w", clean, err) } } return nil}Authentication crypto specifics
JWT: pin the signing algorithm, never trust the token’s own alg
A token carries an alg header claiming how it was signed. Deciding which verifier to use from that claim lets an attacker switch a service from RS256 to HS256 (or to none) and forge a token the server will accept. Pin the expected algorithm in code, and reject anything else before checking the signature.
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return hmacSecret, nil}, jwt.WithValidMethods([]string{"HS256"})) // explicit allowlist, not derived from the tokenif err != nil { return domain.Claims{}, fmt.Errorf("verify token: %w", err)}Password hashing: Argon2id with OWASP parameters
Never bcrypt with library defaults and never an unsalted hash of any kind. Argon2id with OWASP’s recommended baseline resists both GPU cracking and side-channel timing attacks.
const ( argonMemoryKiB = 64 * 1024 // 64 MiB argonIterations = 3 argonParallelism = 4 argonKeyLen = 32 argonSaltLen = 16)
func hashPassword(password string) (string, error) { salt := make([]byte, argonSaltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("generate salt: %w", err) } hash := argon2.IDKey([]byte(password), salt, argonIterations, argonMemoryKiB, argonParallelism, argonKeyLen) return encodePHC(salt, hash), nil // PHC string: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>}Cookies: __Secure- and __Host- prefixes
The prefix is enforced by the browser itself — a cookie named __Host-session is rejected outright unless it also sets Secure, has Path=/, and omits Domain. __Secure- requires only Secure. Prefer __Host- for anything scoped to one origin, like a session cookie.
Bad — no prefix, no Secure, no HttpOnly, no SameSite:
c.Cookie(&fiber.Cookie{ Name: "session", Value: sessionID,})This cookie is sent over plain HTTP, readable by any script on the page, and attached to cross-site requests — three separate weaknesses in four lines.
Good — __Host- prefixed and fully locked down:
c.Cookie(&fiber.Cookie{ Name: "__Host-session", Value: sessionID, Secure: true, HTTPOnly: true, SameSite: fiber.CookieSameSiteStrictMode, Path: "/",})Envelope encryption for key rotation
Encrypt bulk data with a data-encryption key (DEK); encrypt the DEK itself with a key-encryption key (KEK) held in a KMS or HSM. Rotation re-wraps only the DEK — a few bytes — and never touches the bulk ciphertext, no matter how large it is.
type EnvelopeEncryptor struct { kek KeyEncryptionKey // lives in the KMS; the raw key material never leaves it}
func (e EnvelopeEncryptor) Encrypt(ctx context.Context, plaintext []byte) (Envelope, error) { dek, err := generateDEK() // random 256-bit key, exists only in process memory if err != nil { return Envelope{}, fmt.Errorf("generate dek: %w", err) }
ciphertext, err := sealAESGCM(dek, plaintext) if err != nil { return Envelope{}, fmt.Errorf("seal payload: %w", err) }
wrappedDEK, err := e.kek.Wrap(ctx, dek) // one KMS call; wraps the DEK, not the payload if err != nil { return Envelope{}, fmt.Errorf("wrap dek: %w", err) }
return Envelope{Ciphertext: ciphertext, WrappedDEK: wrappedDEK}, nil}To rotate, call the KMS to unwrap every stored WrappedDEK with the old KEK and re-wrap it with the new one — the Ciphertext field is never read or rewritten. Rotation cost is proportional to the number of DEKs, not the size of the data they protect.
pprof: hardening the existing gate
references/observability.md already requires pprof on a separate internal listener gated by DEBUG=true. From a security standpoint, that gate is necessary but not sufficient on its own: DEBUG=true controls whether pprof starts, not who can reach it. Reinforce the existing rule with actual network-level or auth-level containment:
- Bind the pprof listener to
127.0.0.1or a cluster-internal interface — never0.0.0.0— so it is unreachable from outside the pod/host even ifDEBUG=trueleaks into a deployed environment. - If pprof must be reachable across a network boundary (e.g., through a debug sidecar), put basic auth or an mTLS-only route in front of it. Never rely on the port number alone as a defense.
- A pprof endpoint that leaks memory contents (heap dumps, goroutine stacks with request data) is a data-exposure bug, not just a debug convenience — treat an internet-reachable pprof port as a Critical finding, the same severity as an unauthenticated database connection.