Skip to content

Security

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/security.md
DescriptionNot 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

SeverityCategoryRequirementWhy it matters
CriticalInput validationValidate at the handler with the OpenAPI-generated types (references/openapi.md); reject unknown fields and out-of-range values before calling the serviceThe service and repository trust their callers; a handler that lets malformed input through poisons every layer behind it
CriticalSQL injectionEvery query is parameterized ($1, $2, …) via pgx/GORM; never fmt.Sprintf a value into SQLString-built SQL is the single most common path to full data exfiltration
CriticalCryptographyUse vetted primitives only — crypto/*, golang.org/x/crypto/* — never a hand-rolled cipher or hashCustom crypto fails in ways that don’t show up until an attacker finds them
HighWeb: CSRFState-changing requests (POST/PUT/PATCH/DELETE) from a browser session require a CSRF token or SameSite=Strict/Lax cookiesWithout it, any site the user has open can trigger authenticated requests on their behalf
HighWeb: CORSAccess-Control-Allow-Origin is an explicit allowlist, never * alongside credentialed requestsA wildcard origin with credentials lets any site read authenticated responses
CriticalAuthenticationVerify token signature and expiry on every request; never trust a client-supplied claim without verificationAn unverified token is equivalent to no authentication at all
CriticalAuthorizationCheck the resource belongs to the caller (row-level check), not just that the caller is logged inAuthentication proves identity; only authorization proves the caller may touch this invoice
HighError handlingMap 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
HighDependenciesRun govulncheck in CI; pin versions in go.sum; review before bumping a major versionMost exploited vulnerabilities are in dependencies, not first-party code
MediumSecurity headersSet Content-Security-Policy, X-Content-Type-Options: nosniff, Strict-Transport-SecurityBrowser-enforced defenses that cost one middleware and block whole exploit classes
HighRate limitingApply per-IP and per-account limits on auth and write endpointsWithout it, credential stuffing and abusive writes are unbounded
HighConcurrency (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 callsA 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.

internal/handler/invoice.go
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 token
if 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.1 or a cluster-internal interface — never 0.0.0.0 — so it is unreachable from outside the pod/host even if DEBUG=true leaks 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.