harden-backend
Use when a backend service is about to take untrusted traffic or fails a security review. Adds edge validation, rate and size/time limits, a CORS allow-list, API security headers, SSRF-safe outbound f
SKILL.mdskills/backend/harden-backend/SKILL.md
GitHubHarden Backend
Nine controls that decide whether untrusted traffic breaks the service. Rules and rationale: harden-patterns.md. Code per track: harden-tracks.md. Baseline: security-baseline.md rules 4, 6, 7, 8 and 13. Seam paths: service-layout.md. The per-caller rate limit keys on the Principal of set-up-backend-auth; run that first. For a read-only review with severities and no changes, use audit-security.
1. Audit (change nothing)
Read the profile first; detect only what it leaves open (stack-profile.md).
cat .claude/stack-profile.md ~/.claude/stack-profile.md 2>/dev/null # backend.track, package_manager, ci
ls src/transport/edge.ts internal/transport/httpapi/edge.go src/*/transport/edge.py internal/platform/safehttp src/*/platform/outbound.py 2>/dev/null
# Wildcard CORS (Hono / FastAPI / Go)
grep -rn -e "origin: '\*'" -e 'allow_origins=\["\*"\]' -e 'AllowedOrigins: \[\]string{"\*"}' --include='*.ts' --include='*.py' --include='*.go' . 2>/dev/null
# String-built SQL: SQL keywords inside template literals, f-strings or Sprintf
grep -rnE '(SELECT |INSERT INTO|UPDATE |DELETE FROM)' --include='*.ts' --include='*.py' --include='*.go' src internal 2>/dev/null | grep -E '\$\{|f"|Sprintf'
# Outbound calls with a variable URL (SSRF surface)
grep -rnE 'fetch\(|httpx\.|http\.Get\(' --include='*.ts' --include='*.py' --include='*.go' src internal 2>/dev/null
# Raw errors handed to clients
grep -rnE 'err\.message|str\(exc\)|err\.Error\(\)' --include='*.ts' --include='*.py' --include='*.go' src internal 2>/dev/null
# Body limits, deadlines, server timeouts
grep -rn 'bodyLimit\|MaxBytesReader\|MAX_BODY_BYTES' --include='*.ts' --include='*.go' --include='*.py' src internal 2>/dev/null
grep -rn 'http.Server{' --include='*.go' . 2>/dev/null # no ReadHeaderTimeout beside it = no timeouts
# CI scanning
ls .github/workflows 2>/dev/null && grep -rlE 'gitleaks|osv-scanner|audit' .github/workflows 2>/dev/null
Each fetch/httpx/http.Get hit needs one question answered: is the URL hardcoded, or does request data reach it? Fill the checklist; the "where" column names the files you saw, the OWASP IDs follow harden-patterns.md:
| Control | Present? | OWASP 2025 | Where |
|---|---|---|---|
| Edge schema validation, unknown fields rejected | A05, A06 | ||
| Parameterized SQL only | A05 | ||
| Per-caller rate limit | A06, A07 | ||
| Body cap, request deadline, server timeouts | A06, A10 | ||
| CORS exact allow-list | A02 | ||
| API security headers | A02 | ||
| SSRF-safe outbound fetch | A01 | ||
| problem+json errors, generic 500 | A10, A09 | ||
| CI: secret and dependency scanning | A03, A08 |
2. Decide
- Controls missing: apply the delta for each, steps 4 to 6. Do not rewrite what works.
- A control present but weak (wildcard CORS,
err.messageto clients, string-built SQL, a server without timeouts): fix it in place with the matching rule from harden-patterns.md. - Every row present and step 7 passes: report "already in place" and stop.
3. Detect track
backend.track |
Do |
|---|---|
hono, go, fastapi |
Continue; the code is in harden-tracks.md. |
nextjs |
Stop. Use build-nextjs-backend. |
supabase |
Stop. Use secure-supabase-rls and set-up-supabase. |
none or unset |
Run set-up-stack-profile, then a scaffold-*-service skill. |
One more question, answered from the audit grep: does the service call user-influenced URLs (webhooks, link previews, imports)? If it does not, skip the SSRF module and say so in the report: install it only when such a call first appears. Everything else applies regardless of the answer.
4. Install only what is missing
pnpm add hono-rate-limiter undici # hono: limiter middleware; fetch with a connect-time guard (zod is already there)
go get github.com/rs/cors golang.org/x/time # go: CORS allow-list; token-bucket rate limiter
uv add slowapi httpx # fastapi: per-route limiter; the client the SSRF module uses
Use the profile's package manager. Verify each line in stack-versions.md before you write it.
5. Generate the seams
Create from harden-tracks.md, skipping files that exist:
transport/edge CORS allow-list (outermost), secure headers, body cap, request deadline, limiter factory
transport decode (Go only) JSON with unknown fields and trailing data rejected
platform outbound seam the only way out to a URL the service did not hardcode
tests/unit SSRF refusals, rate limit, decode
.github/workflows/security.yml secrets + dependency scanning, SHA-pinned actions
Paths follow service-layout.md.
6. Wire
- Edge defaults on the app. Order matters: CORS outermost, so a preflight is answered before any body limit or deadline runs.
- Body parsing through strict schemas. Unknown fields rejected, every string, array and number bounded.
- Per-caller rate limit after authentication. The key is the principal, not the IP; tighter on login and token endpoints.
- Deadline on the request context; server timeouts in main.
net/httphas none by default. - Error mapper. problem+json; the unknown error becomes a generic 500; its detail goes to the logger once; 405 keeps
Allow; 404 for another tenant's rows. - SQL review. Every hit from the audit grep moves to a parameterized form; dynamic identifiers come from an allow-list map.
- Outbound calls. A URL that is not hardcoded goes through the SSRF seam only.
- CI. Commit
.github/workflows/security.yml; keep the native audit of your track, delete the other two.
7. Verify
pnpm typecheck && pnpm vitest run # hono
go vet ./... && go test ./... # go
uv run mypy && uv run pytest # fastapi
Expected: all green. Then manual checks against a running service, each with its expected result:
- a body over the cap: 413;
- a request with an unknown
Origin: noaccess-control-allow-originin the response; curl -si <URL>/healthz: response headers includecontent-security-policy,strict-transport-security,x-content-type-options;- one request over the limit: 429 with
Retry-After; - the scanners, locally, with the docker commands from the workflow.
Then fuzz the contract with schemathesis (install: uvx schemathesis or pipx install schemathesis):
schemathesis run http://localhost:8299/openapi.json --checks all -n 25 --exclude-path '/documents/{doc_id}'
Expected on the FastAPI scaffold: 55 generated, 55 passed. The excluded path needs a token the fuzzer has no credentials for. On the unmodified scaffold the first run FAILED with TRACE returned 405 without required Allow header: the scaffold's StarletteHTTPException handler dropped the exception's headers. The fix (headers=dict(exc.headers) if exc.headers else None) is in the Python track. That finding is why the fuzzer is part of hardening: it breaks the HTTP contract in ways the test suite does not.
References
- harden-patterns.md: the nine controls, each justified and mapped to OWASP Top 10:2025.
- harden-tracks.md: edge, decode, SSRF and CI code for Hono, Go and FastAPI, plus the wiring deltas.
- set-up-backend-auth: authentication; the rate limit and the 404 rule build on it.
- security-baseline.md: the cross-stack floor this skill implements for services.
- audit-security: the read-only review to run when nothing should change yet.