SKILL.md
Security and Hardening
Treat every external input as hostile, every secret as compromised the moment it leaks, and every authorization check as mandatory. Security is a constraint on each line that touches user data, not a phase.
Threat Model First (5 minutes, always)
Controls without a threat model are guesses. Before hardening:
- Map trust boundaries. Where does untrusted data enter? HTTP requests, forms, uploads, webhooks, third-party APIs, queues, and LLM output. Each boundary is attack surface.
- Name the assets. Credentials, PII, payment data, admin actions, money movement.
- Run STRIDE per boundary. Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege. One question each, one mitigation each.
- Write abuse cases next to use cases. "How would I misuse this?" becomes your first test.
If you cannot name the trust boundaries, you are not ready to secure the feature.
The Three Tiers
Always, no exceptions: validate all input at the boundary, parameterize every query, encode output (keep framework auto-escaping on), HTTPS everywhere, hash passwords with bcrypt/scrypt/argon2, set security headers (CSP, HSTS, X-Frame-Options), httpOnly + secure + sameSite session cookies, run npm audit (or equivalent) before release.
Ask first (human approval): new auth flows, new categories of sensitive data, new external integrations, CORS changes, file upload handlers, rate limit changes, elevated roles.
Never: secrets in version control, sensitive data in logs, client-side validation as a security boundary, eval() or innerHTML with user data, auth tokens in localStorage, stack traces exposed to users.
Read references/security-checklist.md for code patterns per vulnerability class (injection, XSS, access control, SSRF, uploads, rate limiting, headers) and the full pre-commit review checklist.
LLM Features Are Attack Surface
If the app calls a model:
- Treat model output as untrusted input. Never pass it to eval, SQL, a shell,
innerHTML, or a file path without validation and encoding. - The system prompt is not a security boundary. Enforce permissions in code. Any untrusted text in the context can carry injected instructions.
- Keep secrets and cross-tenant data out of prompts; anything in context can be echoed back.
- Scope tool permissions to minimum, confirm destructive actions, validate every tool argument.
- Cap tokens, request rate, and recursion depth.
- In RAG, partition embeddings per tenant and validate documents before indexing.
Dependency Triage
Critical or high finding that is reachable in production: fix now. Fix unavailable: workaround, replace, or allowlist with a review date, in that order. Moderate and reachable: next release. Dev-only or low: backlog with a date. Document every deferral.
npm audit misses malicious packages. Also: commit the lockfile, install with npm ci in CI, review new dependencies before adding (maintenance, downloads, postinstall scripts), watch for typosquats.
If a secret is ever committed, rotate it. Deleting the line is not enough. Revoke and reissue first, purge history second.
Verification
Run npm audit --audit-level=high. Expect exit code 0. If not, triage per the rules above before shipping.
Run git diff --cached | grep -iE "password|secret|api_key|token" before every commit touching config. Expect no matches on real values. If a real value matches, unstage, move it to the environment, and rotate it.
Hit a protected endpoint without a token and with another user's token. Expect 401 and 403 respectively. Any 200 is a broken access control bug, fix before anything else.
Good vs Bad
Bad: "It's an internal tool, I'll skip authorization checks and validate on the client." Internal tools get compromised and attackers pivot through the weakest link. Client validation is UX, not security.
Good: Same internal tool ships with parameterized queries, per-resource ownership checks, and secrets in the environment. Cost: an hour. Retrofit cost after a breach: weeks plus disclosure.
Footguns
- Authenticated but not authorized. The endpoint checks login, not ownership. Fix: compare
resource.ownerIdtoreq.user.idon every mutating route. - SSRF via user-supplied URLs. Webhooks and "import from URL" aimed at cloud metadata or localhost. Fix: allowlist scheme and host, resolve DNS and reject non-unicast IPs, forbid redirects. Pattern in references/security-checklist.md.
- Wildcard CORS with credentials.
origin: '*'plus cookies leaks sessions. Fix: explicit origin list from the environment. - Secrets "removed" by rewriting history. The key already reached a remote or a fork. Fix: rotate first, always.
Red Flags: Stop and Re-check
- "This is internal, security doesn't matter."
- "We'll add security later."
- "No one would try to exploit this."
- "The framework handles it."
- "It's just a prototype."
- "It's just LLM output, it's only text."
Each phrase is a rationalization, not a risk assessment. There is no configuration of these excuses that makes skipping a boundary check acceptable. If a control genuinely does not apply, write down why, in the PR, before skipping it.
Completion Checklist
- [ ] Trust boundaries and assets named for the feature
- [ ] All input validated at the boundary, all queries parameterized
- [ ] Authz (ownership/role) checked on every protected route, verified with a foreign-token request
- [ ] No secrets in code, logs, or prompts; audit clean or deferrals documented
- [ ] Security headers, cookie flags, and CORS origins verified in a real response
- [ ] LLM output validated and encoded before use (if applicable)
Any box unchecked: not done. Fix or say so.