Security Review Baseline
What every change is checked against before it merges, by its author and by its reviewer. It covers what the CI scanners cannot see: the scanners find known-vulnerable modules, leaked secrets and bad Dockerfiles; they do not find a missing ownership check or a query built from a string.
MUST, SHOULD and MAY are used as in RFC 2119. Each section names the aioz-common package that already solves the problem; use it instead of writing your own.
Related baselines, not repeated here:
| Topic | Where |
|---|---|
| Access tokens, refresh tokens, one-time codes, JWT, signing keys | token-security.md |
| Base images, non-root, digest pinning, build secrets | container-images.md |
| govulncheck, gitleaks, trivy, hadolint and how to clear a finding | ci-cd.md |
Migrator role, .env.migrate, deploy credentials | release.md |
How a review uses this
- Read the whole diff, not only the files the merge request description mentions.
- For each changed file, go through the sections below that apply to it. An HTTP handler touches at least authorization, input and the HTTP surface; a store touches injection; a Dockerfile or compose file touches containers and deploy.
- Report each finding with its file and line, its severity, what an attacker does with it, and the fix.
- A finding at High or above blocks the merge. A Medium is fixed in the same merge request or gets a Jira subtask linked from it before merge.
| Severity | Meaning | Example |
|---|---|---|
| Critical | Anyone can reach data or control they should not, now | A secret committed; SQL injection on a public endpoint; an endpoint with no auth |
| High | A logged-in user can reach another user's data or act as them; a crash or exhaustion any client can trigger | Missing ownership check; unbounded request body; SSRF to the internal network |
| Medium | Needs another weakness to be exploited, or exposes something minor | Stack trace in an error response; missing rate limit on a non-auth endpoint |
| Low | Hardening | A missing security header on an internal-only service |
Changes touching auth, crypto or PII need two reviewers, and the merge request says so.
Secrets and configuration
- Secrets MUST NOT appear in code, config files in git, images, CI logs, tickets, chat or application logs. A secret that reached any of them is compromised: rotate it first, then remove it and add the gitleaks fingerprint with a reason.
- Secrets MUST come from the environment or from files outside the image (mode
0600, directory0700), loaded at startup. Hold them insecret.Tokenso they print as redacted. - Configuration MUST be validated at startup, and the process MUST refuse to start on a missing or invalid security setting rather than fall back to an open default.
- Default values MUST be safe: an empty allowlist allows nothing, an empty CORS origin list allows no origin, a debug or test flag defaults to off.
- Test-only accounts, fixed codes or bypass flags MUST NOT exist in production config; where they exist at all they are behind config that is empty by default.
- The debug listener (pprof, metrics,
/healthdetails) MUST bind to loopback or a private network only.
Authentication
- Every endpoint MUST be authenticated unless it is on an explicit, reviewed list of public routes. Authentication is applied by router middleware for the whole group, never remembered per handler.
- Tokens follow
token-security.md. - Passwords MUST be hashed with aioz-common
password(Argon2id), never a fast hash, and compared through itsVerify. - Login, OTP verification, password reset and any "does this account exist" endpoint MUST be rate limited per account and per client IP, and MUST answer the same way for an unknown account as for a wrong secret.
Authorization
The most common serious finding, and the one no scanner catches.
- Every request that names a resource by id MUST check that the caller may access that resource: the record's owner, tenant or organisation is compared with the caller's, in the query (
WHERE id = $1 AND owner_id = $2) or immediately after loading it. A valid token is not permission. - The check MUST live in the service layer, so every transport (HTTP, gRPC, consumer) gets it.
- List endpoints MUST scope by the caller in the query, never filter after loading.
- Role and admin checks MUST be default-deny: an unknown role gets nothing.
- The caller's identity MUST come from the verified token, never from a body field, query parameter or header the client controls (
user_id,X-User-Id,is_admin). - Updates MUST bind to an explicit DTO of writable fields; never decode a request straight into a model that has
owner_id,role,balanceorstatus(mass assignment). - A not-found and a not-yours answer the same (
404), so ids cannot be probed. - A test MUST prove that user B gets
404for user A's resource, for every new endpoint that takes an id.
Input and limits
- Every request body MUST be bounded (
http.MaxBytesReaderor the router's limit); a body over the limit is413, never read to the end. - Every string, array and number in a DTO has a validated maximum length, size or range; enums are checked against their allowed values.
- List endpoints paginate with a server-side maximum page size (see
RESTfulAPI.md); a client cannot ask for everything. - The HTTP server MUST set
ReadHeaderTimeout,ReadTimeout,WriteTimeout(or per-route deadlines for streaming) andIdleTimeout; Go's zero values mean no timeout. - A regular expression MUST NOT be compiled from user input.
- Decoding refuses unknown fields where the API is strict, and never decodes into
interface{}to switch on later.
Injection
- SQL. Queries MUST be parameterized: pgx placeholders, gorm
Where("x = ?", v), neverfmt.Sprintfor string concatenation with a value. Identifiers that cannot be parameters (a sort column, a direction) MUST be mapped from an allowlist, never passed through. gormRaw,Exec,Order,GroupandSelectwith a string from the request are injection points. - Commands.
exec.Commandtakes the program and its arguments separately; neversh -cwith any part from input. An argument from input is preceded by--where the program supports it. - Templates. HTML is rendered with
html/template, nevertext/template; nothing user-provided is markedtemplate.HTML. - Logs. Logging is structured (
loggerfields), so user input lands in a field and cannot forge a log line. - Headers and redirects. A redirect target from input MUST be checked against an allowlist of hosts or be a relative path; never echo input into a response header.
Outbound requests (SSRF)
- Any request to a URL that came from a user, a webhook registration, a feed or an uploaded document MUST go through an aioz-common
httpclientbuilt withNewOneShotor with the private-network guard on, which refuses loopback, link-local (including the169.254.169.254metadata endpoint), RFC 1918 and other internal ranges after DNS resolution. - Every outbound client MUST set its dial, TLS handshake and overall timeouts explicitly when it is built in code: a zero
httpclient.Config{}has none. - Outbound responses are read through a size limit.
- TLS verification MUST NOT be disabled (
InsecureSkipVerify) outside a test.
Files and paths
- Uploads MUST go through aioz-common
upload.Policy: a bounded size, an allowlist of types checked against the content and the extension, and the client'sContent-Typeignored. - A filename from a client is never used as a path. Store under a generated name; show the original only through
upload.DisplayName. - A path built from any input MUST be resolved inside a fixed root (
os.Root, orfilepath.Cleanand a prefix check on the result) so../cannot leave it. - Archives are extracted with the same check per entry, and with limits on entry count and total size.
The HTTP surface
- Every API wraps its router with aioz-common
httpsecNewHeaders(...).Wrap, which sets the OWASP header set (HSTS,nosniff, frame denial, a strict default CSP, no-store). - CORS goes through
httpsec.NewCORSwith an explicit origin allowlist.*MUST NOT be combined with credentials, and an origin is never reflected back unchecked. - An endpoint authenticated by cookie MUST be protected against CSRF (
SameSite=Laxor stricter, plus a token or anOrigincheck on state-changing requests). Cookies holding a session areSecure,HttpOnlyandSameSite. - The client IP MUST come from aioz-common
clientip, configured with the real trusted proxies; readingX-Forwarded-Fordirectly lets any client choose its IP, and with it its rate-limit bucket. - Rate limits use aioz-common
ratelimit(Redis-backed, shared across replicas), with an explicitOnErrorchoice:closedfor login and OTP,localoropenelsewhere. - Responses with tokens, credentials, personal or financial data MUST use the
no-storecache policy; a response that depends on the caller and may be stored isprivatewithVary: Authorization(RESTfulAPI.md). - Error responses go through
apierr: a stable code and a message for the client, never a stack trace, SQL error, file path or internal host name. The detail goes to the log with the request id.
Data and privacy
- Logs MUST NOT contain tokens, codes, passwords, keys, full card or bank numbers, or government ids; personal data (email, phone, address, precise location) is logged only where needed and never in bulk. Correlate by user id or request id instead.
- An endpoint returns only the fields its DTO declares; a model is never serialized directly (it leaks hashes, internal flags and other users' ids).
- Personal data collected is the minimum the feature needs, and the merge request says what is stored and for how long.
- Traffic is TLS end to end where it leaves the host; a database or broker connection across hosts uses TLS.
Cryptography and randomness
- Anything secret or unguessable (tokens, codes, ids that grant access, salts, nonces) comes from
crypto/rand, through aioz-commonsecretorrandom; nevermath/rand. - Secrets and MACs are compared in constant time (
subtle.ConstantTimeCompare, or the package that issued them). - No hand-rolled cryptography: no custom ciphers, no ECB, no reused nonces, no MD5 or SHA-1 for anything security-relevant.
Resources and denial of service
- Every request, job and outbound call runs under a context with a deadline.
- No unbounded growth from client input: maps, caches (use aioz-common
lrucache), goroutines per request, queue payload sizes. - Expensive endpoints (search, export, report, anything that fans out) are paginated or asynchronous, and rate limited.
- Consumers bound message size and refuse a payload they cannot parse instead of retrying it forever.
Dependencies
- A new direct dependency is justified in the merge request: maintained (a release in the last year), widely used or reviewed, license compatible. Prefer aioz-common or the standard library.
- No
replaceto a personal fork or a local path onmain. - govulncheck findings are fixed by upgrading, never ignored (see
ci-cd.md).
Containers and deploy
- Images follow
container-images.md: approved base, tag and digest, non-root65532:65532, build secrets through BuildKit--secret. - Compose publishes data stores and debug ports on
127.0.0.1only, unless the host is firewalled and the merge request says why. - Host files with secrets (
.env,.env.migrate, config) are0600or0640with a group, never world-readable. - The migrator database role has DDL rights and is used only by
deploy.sh migrate; application roles have DML only, on their own schema. - CI deploy variables are environment-scoped, protected and masked; SSH keys are written for the job and shredded in
after_script.
Sources
- OWASP Application Security Verification Standard 4.0.3, chapters V1-V14.
- OWASP Top 10 2021, and the OWASP API Security Top 10 2023 (API1 object-level authorization, API3 property-level authorization, API4 resource consumption, API7 SSRF).
- CWE Top 25 (2024).