⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
paranoid
>-
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
// README
🕵️ paranoid
An agent skill that pentests the app you're building. /hack-me attacks your own
running app the way an attacker would, then closes what it finds:
find → prove → patch → re-verify
Nothing is called a bug until a real HTTP request proves it, no fix is done until that exact exploit stops working, and no class is closed until the same pattern is swept from the paths no probe touched.
Quickstart
Claude Code — one install, gets you the skill and the pentest loop:
/plugin marketplace add kulchankas/paranoid
/plugin install paranoid@paranoid
Then start your app and point it at the loop — plugin skills are namespaced, so
it's /paranoid:hack-me here:
python3 my_app.py # your app, running locally
/paranoid:hack-me # → http://localhost:<port>
Codex, Cursor, or any agent that reads skills:
npx skills add kulchankas/paranoid/skills/paranoid
Copy commands/hack-me.md into your agent's commands
directory (e.g. .claude/commands/) and it's plain /hack-me.
No dependencies, no network calls, no telemetry — it's Markdown your agent reads.
Receipts
On an app we didn't write
The hard claim is code we don't control. Pointed at OWASP VAmPI
— a well-known third-party vulnerable API — with nothing but its URL, /hack-me
found, proved, patched and re-verified six real bugs:
| # | Finding | OWASP API | Status |
|---|---|---|---|
| 1 | Unauth /users/v1/_debug dumps every password | API3/5 | 401/403 |
| 2 | Read any user's private book secret (BOLA) | API1 | 404 |
| 3 | Register with admin:true → privilege escalation | API6 | admin=false |
| 4 | Change any user's password (account takeover) | API1 | victim untouched |
| 5 | SQLi in user lookup (UNION-dumps passwords) | API8 | 404 |
| 6 | Debugger + stack traces exposed | API7 | clean errors |
Notably, VAmPI's own global "secure mode" flag closed only four of the six — the
critical password dump stayed open until patched. /hack-me caught it by
replaying every exploit instead of trusting the flag. Full receipts:
examples/vampi/HACKME_REPORT.md.
On a demo app, start to finish
Against a small invoicing API (examples/ledgerlite), an
agent told nothing about the app's bugs found four by probing:
| # | Found by probing the API | Class | After patch |
|---|---|---|---|
| 1 | Any user reads any invoice | IDOR / broken object auth | 404 |
| 2 | /admin/users open to anyone logged in | broken function auth | 403 |
| 3 | /search?email= SQL injection (dumped passwords) | SQLi | [] |
| 4 | /profile accepts is_admin | mass assignment → privesc | 400 |
Every legitimate request still returns 200 afterwards. Reproduce it yourself:
python3 examples/ledgerlite/app.py, then run /hack-me. Walkthrough with exact
requests and diffs: examples/ledgerlite/HACKME_REPORT.md.
On a shop with no exploit menu
DVWA lists its bug categories in the navigation, so that run
tests the fix loop, not discovery. VulnShop
renders as a store (NexCart): products, search, account. Pointed at that UI,
/hack-me proved five bugs and re-verified each — including a product-id
UNION that printed every seed password, and a login of admin' OR '1'='1
that came back as an admin session:
| # | Finding | Status |
|---|---|---|
| 1 | /products?id= dumps every password | empty listing |
| 2 | Login bypass to an admin session | no session |
| 3 | Search reflects raw HTML | escaped |
| 4 | Any logged-in user opens /admin | 403 |
| 5 | Profile save with no CSRF token | 400 |
The upstream README names those classes, so this is not a fully blind-to-the-repo
find. Receipts: examples/vulnshop/HACKME_REPORT.md.
What /hack-me actually does
- Maps your running app and picks the risk classes it's exposed to.
- Probes each — one crafted request that only succeeds if the bug is real.
- Proves every finding with the actual request/response (no theorizing).
- Patches the root cause with a minimal, behavior-preserving fix.
- Re-verifies by replaying the exact exploit — a finding isn't closed until it fails.
- Sweeps for the same bug on paths no probe touched — a sibling branch, the
same resource under a different method, a
/v1copy, a cron job that reaches the same sink. A green re-verify proves the request is dead, not the class.
Step 6 exists because the loop got caught by exactly that: in our own DVWA run an SQL injection was proven, patched and re-verified green while an identical injection sat in the same file, in the branch for the other database backend. Review caught it; the loop hadn't. Anything the sweep fixes but can't reach with a request is reported as "same pattern, fixed, not separately proven" — never as verified.
It knows where routes and auth live in twelve stacks (Next.js, FastAPI, Express,
Django, Rails, Flask, Spring Boot, Laravel, Phoenix, Go, NestJS, ASP.NET Core) — see
references/frameworks.md. Guardrails
apply throughout; see Scope & ethics.
Why this isn't another "write secure code" skill
It started as the obvious thing — guidance telling the agent to write secure code
— and then got benchmarked honestly before anyone believed it. The harness
(benchmark/) generates the same tasks with and without the skill
and runs real exploits against whatever the model writes.
The result was a clean negative:
| Model | Tasks | Exploit rate without skill | with skill | Effect |
|---|---|---|---|---|
| Opus | all 22 classes, blinded | 0% | 0% | none |
| Opus | 3 isolated functions | 0% | 0% | none |
| Fable 5.1 | 3 isolated functions | 0% | 0% | none |
On an isolated function a capable model already writes the secure version
unprompted — ownership in the WHERE clause, parameterized queries, field
allow-lists. Advice adds nothing there. (The harness isn't rigged: it flags
deliberately-insecure reference code at 100% and secure code at 0%, and CI
asserts that on every push.)
Blinded matters here: the task ids name their own vulnerability, so asking a
model for sqli_login.py is itself a security hint. The 22-class run was redone
with the specs renamed task_01…task_22, generated outside the repo, with no
mention of a benchmark — and the result held. The skill did, however, cost a
functional test the baseline passed. [Full
// HOW IT'S BUILT
// FILES
9 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/paranoid/SKILL.md5.9 KB00a7df24ab03upstream ↗
--- name: paranoid description: >- Write and review code as if it gets pentested tomorrow. Load this whenever code touches a trust boundary: authentication, authorization, user input, database access, secrets or environment variables, file uploads, outbound requests (fetch/webhooks), API or webhook endpoints, or anything that runs raw SQL/shell/HTML. Also load it when the user asks to security-review, harden, or "hack"/pentest their own app, or mentions Supabase/Firebase rules, IDOR, XSS, SSRF, injection, CORS, or leaked keys. --- # paranoid You are reviewing every line you write as if a bored attacker will read it tomorrow. Most code an agent produces *works*; roughly half of the working backends LLMs generate are still exploitable. Your job is to be the half that isn't. Three rules, in order: 1. **Name the trust boundary before you write the feature.** 2. **Apply the secure default for each risk class you cross.** 3. **Try to break it before you call it done.** Keep this lightweight. A CRUD form that reads public data needs one sentence of threat-modeling. A file upload that shells out to `ffmpeg` needs real care. Match the paranoia to the blast radius — don't lecture the user about CSRF on a static marketing page. --- ## 1. Name the trust boundary (one line, out loud) Before writing anything that crosses a boundary, state — in the chat, briefly — who can reach this code and what they control: > *"This endpoint takes a `postId` from the client. Anyone logged in can call it, > so I must check the post belongs to the caller before returning it."* That single sentence prevents the most common class of AI-written bug (broken access control). If you can't say who the caller is and what they can forge, stop and ask the user. ## 2. Secure defaults per risk class When you touch one of these, apply the rule. Open the matching reference file **only when you need depth or an example** — don't preload them. | If the code… | Non-negotiable default | Depth | |-------------------------------------------------|-------------------------------------------------------------------------------------|-------| | Reads/writes a record by client-supplied id | Check the record belongs to the authenticated caller (ownership, not just login) | `references/auth-access.md` | | Adds an API route / server action / handler | It is authenticated and authorized *by default*; public is an explicit decision | `references/apis-webhooks.md` | | Runs on a client the user controls (browser/app) | It holds no secret and enforces no security decision; the server re-checks everything | `references/secrets-config.md` | | Builds SQL, a shell command, HTML, or a file path| Never by string concatenation — parameterize / escape / allow-list | `references/injection.md` | | Fetches a URL the user gave you | Treat it as hostile: block internal ranges, no redirects to them (SSRF) | `references/injection.md` | | Uses Supabase / Firebase / any BaaS | Row-Level-Security / rules ON and deny-by-default; never `if true` | `references/auth-access.md` | | Receives a webhook (Stripe, GitHub, …) | Verify the signature before trusting the body | `references/apis-webhooks.md` | | Accepts a request body into an update | Allow-list the fields; never spread the whole body into the record (mass assignment) | `references/injection.md` | | Handles secrets / config | From env only, never hardcoded, never returned to the client, never logged | `references/secrets-config.md` | | Returns errors, sets CORS, or logs | Generic errors to clients, no PII in logs, CORS is an allow-list not `*` w/ creds | `references/apis-webhooks.md` | The ten failure modes above are the ones LLM-generated code actually ships. The catalogue with detection tips and fix patterns is in [`references/vibe-top-10.md`](references/vibe-top-10.md). ### Hard "never introduce" list Refuse to write these even if asked casually — say why, offer the safe version: - Auth/permission check that lives **only** in the frontend. - `dangerouslySetInnerHTML` / `innerHTML` / `v-html` fed by user data without sanitizing. - SQL built with template strings or `+` around user input. - `eval`, `exec`, `child_process` with a string that contains user input. - Secret, private key, or admin token in code shipped to the browser. - A database rule of `allow read, write: if true` (or RLS disabled) on real data. - Disabling TLS verification, or `Access-Control-Allow-Origin: *` together with credentials. ## 3. Try to break it before "done" For anything that crossed a boundary, do a 30-second adversarial pass and say what you checked: - What happens if I pass **someone else's id**? A **negative / huge / non-numeric** id? - If I call this endpoint **logged out**? As a **different user**? - If the input is 10 MB? Contains `../`, `<script>`, `'; DROP`, a `file://` URL? - If two requests race? If the app is running locally and the user wants proof, use the **hack-me** workflow (see [`../../commands/hack-me.md`](../../commands/hack-me.md)): it exploits the user's *own* app on localhost, shows the bug is real, patches it, and re-runs to confirm the fix. For where routes and auth live per stack (Next.js, FastAPI, Express), see [`references/frameworks.md`](references/frameworks.md). Before committing a feature, run the gate in [`checklists/pre-commit.md`](checklists/pre-commit.md). It's seven questions; if they all pass, ship. --- ## Scope & safety This skill is for securing the **user's own** code and pentesting the **user's own** running app with their say-so. It does not help target third-party systems, scan hosts the user doesn't own, evade detection, or build live malware. If a request drifts that way, stop and say so.skills/paranoid/references/apis-webhooks.md4.2 KB099ed0dc3319upstream ↗
# APIs, webhooks & responses The edge of your app: routes, the webhooks you receive, and what you send back. ## Deny by default Every route is authenticated and authorized unless you consciously make it public. Structure this so "public" is the annotated exception, not the silent default you forgot to change. ```ts // ✓ one wrapper, applied everywhere; public routes are explicit const requireAuth = (h) => async (req, res) => { const s = await getSession(req); if (!s) return res.sendStatus(401); req.user = s.user; return h(req, res); }; app.get('/api/health', publicRoute(handler)); // explicitly public app.get('/api/orders', requireAuth(listOrders)); // default ``` - Don't rely on a route being "internal" or unguessable. If it's reachable, it's reached. Admin endpoints especially. - Middleware order matters: auth must run **before** the handler, and a catch-all shouldn't accidentally bypass it. In Next.js, remember route handlers and server actions are each independently reachable — protect the data layer, not just the page. - Return `401` for "not logged in", `403` for "logged in but not allowed", `404` to hide existence on ownership misses. ## Input validation at the edge Parse every body/query/param through a schema before use (see mass assignment in `injection.md`). Enforce bounds: max length on strings, ranges on numbers, max size on the request body, max items on arrays. Unbounded input is how you get both injection and denial-of-service. ## Rate limiting sensitive routes Login, signup, password reset, OTP, and anything expensive needs a throttle — otherwise: credential stuffing, user enumeration, and cost/DoS. ```ts import rateLimit from 'express-rate-limit'; app.use('/api/auth', rateLimit({ windowMs: 60_000, max: 10 })); ``` - Key by IP **and** account where you can; lock/back off after repeated failures. - Make auth responses uniform and constant-ish time so they don't reveal whether an account exists ("invalid email or password", never "no such user"). ## Webhooks: verify before you trust An unverified webhook is an unauthenticated POST that mutates your data. Anyone can send `{"type":"payment_succeeded"}`. Verify the provider's signature against the **raw** body (not the parsed/re-serialized JSON — re-serializing breaks the signature): ```ts // Stripe — needs the raw body app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = stripe.webhooks.constructEvent( req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET); } catch { return res.sendStatus(400); // bad/forged signature } // event is trustworthy from here }); ``` - GitHub: HMAC-SHA256 over the raw body with your secret, compared to `X-Hub-Signature-256` using a **constant-time** compare (`crypto.timingSafeEqual`). - The usual skip: `JSON.parse(req.body)` and credit the customer with no HMAC check. A forged `payment.succeeded` then lands as an unauthenticated POST. Verify first (constant-time, against the raw bytes), parse after. - Guard against replay: honor timestamps/tolerances and treat delivery ids as idempotency keys so a re-sent event isn't processed twice. ## Responses, errors, logs, CORS, headers - **Errors:** generic message + a correlation id to the client; the stack trace and SQL error go to your server logs only. Never `res.send(err)` / `err.stack` / raw DB errors to the client. - **Logs:** no passwords, tokens, full card numbers, or PII. Redact before logging; assume logs are less protected than your database. - **CORS:** an allow-list of known origins. Never reflect an arbitrary `Origin` with `Access-Control-Allow-Credentials: true` — that's an open door with the user's cookies. `*` is only acceptable for truly public, credential-less APIs. - **Headers:** set the standard set (via `helmet` or equivalent): `Content-Security-Policy`, `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` (or CSP `frame-ancestors`), `Referrer-Policy`. HTTPS everywhere; cookies `Secure`. - **Data shape:** return only the fields the client needs. Serializing a whole DB row leaks `passwordHash`, internal flags, other users' data — select explicitly or use a response DTO.skills/paranoid/references/auth-access.md4.5 KB99ddaa870160upstream ↗
# Auth & access control Broken access control is #1 for a reason: the code looks correct, the tests pass, the happy path works — and any user can read any other user's data. Authentication ("who are you") is not authorization ("may you touch *this*"). You need both. ## Ownership on every object lookup Every read/write keyed on a client-supplied id must be scoped to the caller. ```ts // ✗ authenticated but not authorized const doc = await db.document.findUnique({ where: { id } }); // ✓ ownership is part of the query const doc = await db.document.findFirst({ where: { id, ownerId: req.user.id } }); if (!doc) return res.sendStatus(404); // 404, not 403 — don't confirm it exists ``` For shared resources, check membership rather than a single owner: ```ts const member = await db.membership.findFirst({ where: { projectId, userId: req.user.id }, }); if (!member) return res.sendStatus(404); if (action === 'delete' && member.role !== 'owner') return res.sendStatus(403); ``` Rules of thumb: - Put the caller's id **in the `where` clause**, not in an `if` after the fetch you might forget. - Prefer `404` over `403` on ownership misses so you don't leak existence. - Do the check server-side, every time — never rely on the client hiding a button. - IDs that are enumerable (`1, 2, 3…`) make IDOR trivial; UUIDs help but are **not** an access control — still check ownership. - Multi-route APIs: don't scope `GET /resource/:id` and forget mutations. `PATCH`, `PUT`, and `DELETE` must include the owner check in the mutation query (`where: { id, ownerId }`), not just rely on router-level authentication. ## Function-level authorization Don't gate only by "is logged in." Gate by role/permission for the specific action, on the server: ```ts export const requireRole = (role) => (req, res, next) => req.user?.roles?.includes(role) ? next() : res.sendStatus(403); app.delete('/api/users/:id', auth, requireRole('admin'), handler); ``` Never trust a role sent by the client (`req.body.role`, a JWT claim the client can set, a header). Roles come from your datastore, keyed on the authenticated identity. ## Supabase (Row-Level Security) The anon key is public **by design** — RLS policies are your access control. If RLS is off, the table is world-readable/writable to anyone with the key (which is in your client bundle). ```sql alter table documents enable row level security; -- deny by default, then grant precisely create policy "owner reads" on documents for select using (auth.uid() = owner_id); create policy "owner writes" on documents for insert with check (auth.uid() = owner_id); create policy "owner updates" on documents for update using (auth.uid() = owner_id) with check (auth.uid() = owner_id); ``` - Enable RLS on **every** table holding real data; a table with RLS off and a policy defined is still open — the policy does nothing until RLS is enabled. - The `service_role` key bypasses RLS. It is server-only. Never ship it to the browser, never put it behind a `NEXT_PUBLIC_`/`VITE_` prefix. - `with check` guards writes; `using` guards reads/matches. You usually need both. ## Firebase rules ``` // ✗ the default that ends up in production match /{document=**} { allow read, write: if true; } // ✓ authenticated + ownership + validation match /notes/{id} { allow read: if request.auth != null && resource.data.uid == request.auth.uid; allow create: if request.auth != null && request.resource.data.uid == request.auth.uid; allow update, delete: if request.auth != null && resource.data.uid == request.auth.uid; } ``` Test rules in the emulator; a rule that fails closed is a bug you can see, a rule that fails open is a breach you can't. ## Sessions & tokens (quick hits) - Session cookies: `HttpOnly`, `Secure`, `SameSite=Lax` (or `Strict`). Missing any one of those is a finding — bare `name=value` is readable by XSS, rides cleartext, and is sendable cross-site. - JWTs: verify signature **and** `exp`; pin the algorithm (reject `alg: none` / `None` / `NONE` and algorithm-confusion — match case-insensitively or allow-list the one alg you use). Don't store them in `localStorage` if a cookie will do. - On logout / privilege change, invalidate server-side; a stateless JWT you can't revoke is a liability for sensitive apps. - Password hashes: slow + salted (`bcrypt` / `argon2id` / `scrypt` / PBKDF2 with a per-row salt). Unsalted `md5`/`sha1`/`sha256` of the password is still weak — rainbow-tableable — even though it is not plaintext.skills/paranoid/references/frameworks.md17 KB8d2a3f45a3feupstream ↗
# /hack-me framework guides Where routes and auth live in the common stacks, how to run the app locally, and what to probe first. Use this in the **Map** and **Prioritize** steps of [`/hack-me`](../../../commands/hack-me.md). The proof recipes are the same across frameworks — these guides just tell you where to look and how to talk to the app. Covered here: **Next.js, FastAPI, Express, Django, Ruby on Rails, Flask, Spring Boot, Laravel, Phoenix, Go, NestJS, ASP.NET Core.** For every stack the priority order is the same: **broken access control / IDOR → missing auth → injection → mass assignment → SSRF → info leaks.** Below is where each tends to hide per framework. --- ## Next.js (App Router / Pages Router) **Where routes live** - App Router: `app/**/route.ts` (Route Handlers) and `app/**/actions.ts` / `"use server"` functions (Server Actions). Each is *independently reachable* — a Server Action is a POST endpoint even if no button calls it. - Pages Router: `pages/api/**/*.ts`. - Middleware: `middleware.ts` runs at the edge; auth there does **not** cover Server Actions or the data layer automatically. **Where auth should be** - In the handler/action itself (or a wrapper it calls), re-checking the session server-side. `getServerSession` / `auth()` from NextAuth, Clerk's `auth()`, or a manual cookie/JWT check. Treat any check that only happens in a Client Component or in `middleware.ts` as *absent* for data endpoints. **Run it** - `npm run dev` (default `http://localhost:3000`). Note the actual port from the console; `next dev -p <port>` changes it. **Probe first** - Call `route.ts` handlers directly with `curl` (no cookies) — many assume "the UI is the only caller." → missing auth. - Server Actions: replay the POST the form makes, tampering ids and role/price fields in the body → IDOR / mass assignment. - `NEXT_PUBLIC_*` env vars in the client bundle → leaked secrets (grep the built JS / `.env`). - Any "fetch a URL" API route (og-image, link preview, proxy) → SSRF. --- ## FastAPI (Python) **Where routes live** - `@app.get(...)` / `@app.post(...)` / `APIRouter` decorators, usually under `app/`, `routers/`, or `api/`. `app.include_router(...)` wires them up. - Interactive route map for free: open `http://localhost:8000/docs` (Swagger) or `/openapi.json` — it enumerates every path and its params. **Where auth should be** - FastAPI dependencies: `Depends(get_current_user)` on the route or router. A route with **no** `Depends` guarding identity is public. Check that the dependency actually *authorizes* (ownership/role), not just authenticates. - Ownership must be in the query (`WHERE owner_id == current_user.id`), not an `if` you can forget. **Run it** - `uvicorn app.main:app --reload` (default `http://127.0.0.1:8000`), or `fastapi dev`. Confirm the port from the console. **Probe first** - Pull `/openapi.json`, then hit each path with no `Authorization` header → missing auth. - Endpoints taking a path/query id (`/items/{id}`) as another user → IDOR. - Pydantic models that accept extra fields, or `Model(**body)` / `.dict()`-into-update patterns → mass assignment (watch for `model_config` without `extra="forbid"`). - Raw SQL via `text("... %s" % x)` or f-strings in SQLAlchemy → SQLi. --- ## Express (Node.js) **Where routes live** - `app.get/post(...)`, `router.<verb>(...)` across `routes/`, `controllers/`, or a single `server.js`/`index.js`. Middleware chains matter: the order of `app.use(...)` decides whether auth runs before a handler. **Where auth should be** - An auth middleware (`requireAuth`, `passport.authenticate`, a JWT verifier) applied to the route or router. A handler registered *before* the auth `app.use`, or on a router that never mounts it, is public. - After auth, look for the authorization gap: `req.user` exists but the DB query doesn't scope to `req.user.id`. **Run it** - `npm start` / `npm run dev` / `node server.js`. Port is usually `process.env.PORT || 3000` — read it from the console. **Probe first** - Hit each route with no cookie/token → missing auth; then as the wrong user with someone else's id → IDOR. - `data: req.body`, `Object.assign(row, req.body)`, `...req.body` into an update → mass assignment (try smuggling `role`/`isAdmin`). - `db.query(\`... ${x}\`)`, `.raw()`, or `child_process.exec(\`... ${x}\`)` → SQL / command injection. - `res.send(err)` / stack traces to the client, and `cors({ origin: true, credentials: true })` → info leak / permissive CORS. --- ## Django (Python) **Where routes live** - `urls.py` (`urlpatterns`) maps paths to views in `views.py` or DRF `ViewSet`s/`APIView`s under an app dir. `django-admin` mounts `/admin/`. **Where auth should be** - `@login_required` / `LoginRequiredMixin` for authentication; **object** permissions are the gap — a `get_object_or_404(Model, pk=...)` with no owner filter is IDOR. Scope querysets: `Model.objects.filter(owner=request.user)`. - DRF: `permission_classes` on the view, plus `get_queryset()` scoped to `self.request.user`. `AllowAny` is an explicit decision, not a default. **Run it** - `python manage.py runserver` (default `http://127.0.0.1:8000`). **Probe first** - Detail/edit/delete views taking a `pk`/`slug`, requested as another user → IDOR. - `ModelForm`/serializer with `fields = '__all__'` → mass assignment (can set `is_staff`/`is_superuser`/FK owner). Serializers should list fields explicitly and mark server-owned ones `read_only`. - `.raw()` / `.extra()` / `cursor.execute(f"...")` → SQLi. - `mark_safe(...)` / `|safe` / `format_html` with user data → XSS. - `DEBUG = True` reachable → stack traces + settings leak. ## Ruby on Rails **Where routes live** - `config/routes.rb` (`resources :things`) → `app/controllers/*_controller.rb` actions. `rails routes` prints the full map. **Where auth should be** - A `before_action :authenticate_user!` (Devise) for authn; authorization is the classic Rails gap — scope through the association: `current_user.things.find(params[:id])`, not `Thing.find(params[:id])`. - Strong parameters are the mass-assignment guard: `params.require(:thing).permit(:title, :body)` — never `permit!`. **Run it** - `bin/rails server` (default `http://localhost:3000`). **Probe first** - `Model.find(params[:id])` without `current_user` scope → IDOR. - `permit!` or a permit list that includes `role`/`admin`/`user_id` → mass assignment / privilege escalation. - `where("name = '#{params[:q]}'")` / string-interpolated SQL → SQLi. - `html_safe` / `raw()` on user input → XSS. - Missing `authenticate_user!` on an admin/namespaced controller → missing auth. ## Flask (Python) **Where routes live** - `@app.route(...)` / `@bp.route(...)` decorators across `app.py`, a `views.py`, or blueprints registered with `app.register_blueprint(...)`. **Where auth should be** - A decorator (`@login_required` from Flask-Login, or a hand-rolled `before_request`) — Flask ships with **nothing** on by default, so a route with no decorator is fully public. Ownership is manual: scope every query to `current_user`, don't just check `current_user.is_authenticated`. **Run it** - `flask run` or `python app.py` (default `http://127.0.0.1:5000`; watch for `debug=True`, which serves the interactive Werkzeug console — see VAmPI in [`../../../examples/vampi`](../../../examples/vampi)). **Probe first** - Routes taking an id from the URL/args with no owner check → IDOR; unauthenticated → missing auth. - `db.session.execute(text(f"... {x}"))` / string-built SQL → SQLi. - `render_template_string(user_input)` or `Template(user_input)` → SSTI; user data in `{{ ... | safe }}` → XSS. - `subprocess.run(cmd, shell=True)` with user input → command injection. - A debug/admin route that dumps data with no `is_admin` check → broken function auth (this is exactly VAmPI Finding 1). ## Spring Boot (Java / Kotlin) **Where routes live** - `@RestController` / `@Controller` classes with `@GetMapping` / `@PostMapping` (etc.) methods, usually under a `controller`/`web` package. **Where auth should be** - Spring Security: an `HttpSecurity` config (`SecurityFilterChain`) plus method-level `@PreAuthorize("hasRole('ADMIN')")` / `@PreAuthorize("#id == principal.id")`. A `permitAll()` that's too broad, or a controller with no method security, is the gap. Object ownership belongs in the query or a `@PreAuthorize` SpEL check. **Run it** - `./mvnw spring-boot:run` or `./gradlew bootRun` (default `http://localhost:8080`). **Probe first** - Endpoints with a path variable id and no `@PreAuthorize`/owner check → IDOR; actuator or admin paths reachable → missing/again function auth. - `entityManager.createQuery("... " + x)` / string-concatenated JPQL/SQL → SQLi (use bound parameters / criteria API). - Binding the request body straight onto a JPA entity (`@ModelAttribute` / `@RequestBody Entity`) → mass assignment; use a DTO with explicit fields. - Thymeleaf `[(${...})]` / `th:utext` with user data → XSS. - Exposed Spring Boot Actuator (`/actuator/env`, `/actuator/heapdump`) → secrets leak. ## Laravel (PHP) **Where routes live** - `routes/web.php` and `routes/api.php` map paths to controller methods in `app/Http/Controllers`. `php artisan route:list` prints the full map. **Where auth should be** - The `auth` middleware on the route/group for authentication; **authorization** is Policies/Gates (`$this->authorize('update', $post)`) — the common gap is loading a model by id and returning it without a policy check. Scope through the relationship: `auth()->user()->posts()->findOrFail($id)`. - Mass assignment: rely on `$fillable` (allow-list), never `$guarded = []` plus `Model::create($request->all())`. **Run it** - `php artisan serve` (default `http://127.0.0.1:8000`), or `./vendor/bin/sail up`. **Probe first** - `Model::find($id)` / route-model binding with no policy → IDOR; a route outside the `auth` group → missing auth. - `DB::select("... $q")` / `whereRaw("... $q")` → SQLi (use bindings). - `$request->all()` into `create`/`update` with `$guarded = []` → mass assignment (can set `is_admin`, `role`). - Blade `{!! $userInput !!}` (unescaped) → XSS; `{{ }}` is safe. - `APP_DEBUG=true` reachable → Ignition stack traces + env leak; `.env` served. ## Phoenix (Elixir) **Where routes live** - `lib/<app>_web/router.ex` (`scope`/`pipe_through`) maps to controllers in `lib/<app>_web/controllers`. `mix phx.routes` prints the map. **Where auth should be** - A plug in the router pipeline (e.g. a `:require_auth` plug, or `guardian`) for authentication; ownership is enforced in the context function / Ecto query, not the controller. Scope queries by the current user (`from p in Post, where: p.user_id == ^user.id`). **Run it** - `mix phx.server` (default `http://localhost:4000`). **Probe first** - A controller action taking `params["id"]` and calling a context `get_*!` with no owner scope → IDOR; an action outside the authed pipeline → missing auth. - `Repo.query!("... #{q}")` / string-built SQL → SQLi (use parameterized `from`/`^`). - `raw(user_input)` in HEEx/EEx templates → XSS (`<%= %>` auto-escapes). - `cast/3` including sensitive fields (e.g. `:role`, `:admin`) in the changeset permit list → mass assignment. ## Go (net/http, chi, gin, echo) **Where routes live** - `http.HandleFunc` / `mux.HandleFunc`, or a router (`chi`, `gin`, `echo`) registering handlers, usually in `main.go` or a `handlers/` package. **Where auth should be** - Middleware wrapping the mux/router that populates a user into the request `context.Context`; handlers read `r.Context()`. A handler registered outside the authed middleware chain is public. - Ownership: put the user id in the query (`WHERE id = $1 AND owner_id = $2`), not an `if` after the row loads. **Run it** - `go run .` / `go run ./cmd/server`. Port is usually a flag or `os.Getenv("PORT")` — read the startup log. **Probe first** - Handlers reading `chi.URLParam(r,"id")` / `r.PathValue("id")` with no owner check → IDOR; call them unauthenticated → missing auth. - `db.Query(fmt.Sprintf("... %s", x))` → SQLi (use `db.Query("... $1", x)` / placeholders). - `exec.Command("sh","-c", userStr)` → command injection (pass args, not a shell string). - `text/template` (instead of `html/template`) rendering to HTML → XSS. - `json.Unmarshal` into a struct with server-owned fields that the client can set → mass assignment. ## NestJS (Node.js / TypeScript) **Where routes live** - `@Controller('prefix')` classes decorated with `@Get(...)`, `@Post(...)`, `@Patch(...)`, `@Delete(...)` across `*.controller.ts` files under `src/`. - Controllers are registered in `@Module({ controllers: [...] })` arrays (`*.module.ts`). - Global route prefixes (`app.setGlobalPrefix('api')`) and global pipes/guards in `main.ts` or `app.module.ts`. - Interactive route discovery: if `@nestjs/swagger` is configured, inspect `http://localhost:3000/api` or `/docs`. **Where auth should be** - `@UseGuards(AuthGuard('jwt'))` or custom authorization guards (`@UseGuards(RolesGuard)`) placed at the controller class or method level, or wired globally via `APP_GUARD`. - Routes without an explicit guard (or where `@Public()` / `@SetMetadata('isPublic', true)` bypasses global guards) are public. - In handlers, extract identity via `@Req() req` or custom decorators (`@CurrentUser()`). Authorization / IDOR defense must be scoped in the service/query layer (e.g. `where: { id, userId }` in TypeORM/Prisma), not merely verifying that the caller holds any valid JWT. **Run it** - `npm run start:dev` (default `http://localhost:3000`). **Probe first** - Methods with an id param (`@Param('id')`) calling `service.update(id, dto)` or `service.remove(id)` without validating ownership against `user.id` → IDOR. - Request DTOs without `new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })` registered globally: client can pass extra fields (`role`, `isAdmin`, `isVerified`) directly into TypeORM/Prisma `.save()` or `.update()` → mass assignment. - Raw database queries: TypeORM `repository.query("... " + input)` or `createQueryBuilder().where("... " + input)`, Prisma `$queryRawUnsafe(...)` → SQLi. - Calling endpoints without `Authorization: Bearer <token>` or tampering JWT payload claims → missing / broken auth. - Bypassing built-in sanitization with raw Express `@Res() res` / `@Next() next` → reflected XSS or header injection. ## ASP.NET Core (C# / .NET) **Where routes live** - Controller-based APIs: `[ApiController]` and `[Route("[controller]")]` classes in `Controllers/*Controller.cs`, with `[HttpGet]`, `[HttpPost("{id}")]`, etc. - Minimal APIs: `app.MapGet(...)`, `app.MapPost(...)`, and route groups (`app.MapGroup("/api")`) in `Program.cs`. - Route discovery: Swagger/OpenAPI UI at `http://localhost:5000/swagger` or `/openapi/v1.json` when `app.UseSwagger()` is enabled. **Where auth should be** - `[Authorize]` attribute on the controller class or action method, or `.RequireAuthorization()` chained onto Minimal API route endpoints. - Fallback/default policy in `Program.cs` (`options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()`). Endpoints decorated with `[AllowAnonymous]` bypass all authorization checks. - Ownership / IDOR: EF Core `_context.Items.FindAsync(id)` loads by primary key alone without tenant/owner filtering. Always query with ownership predicate: `_context.Items.Where(x => x.Id == id && x.UserId == currentUserId).FirstOrDefaultAsync()`. **Run it** - `dotnet run` or `dotnet watch` (default `http://localhost:5000` / `https://localhost:5001`). Port configuration in `Properties/launchSettings.json` or `ASPNETCORE_URLS`. **Probe first** - Endpoints taking `{id}` route parameters without user scoping on the EF Core query → IDOR; endpoints missing `[Authorize]` or carrying `[AllowAnonymous]` → missing auth. - Direct entity binding: binding request payload directly to an EF Core entity model (`public async Task<IActionResult> Post(User user)`) instead of an input DTO / record → mass assignment / overposting (can overwrite `IsAdmin`, `RoleId`, etc.). - String-interpolated raw SQL: `_context.Database.ExecuteSqlRaw($"SELECT ... {input}")` or `_context.FromSqlRaw($"... {input}")` → SQLi (use `ExecuteSqlInterpolated` or parameterized queries). - Razor views (`.cshtml`) using `@Html.Raw(userInput)` instead of standard `@userInput` auto-encoding → XSS. - Unhandled exceptions returning detailed stack traces or database connection strings if `app.UseDeveloperExceptionPage()` is active in production/staging environments → info leak. ## Any stack — the constants - **Base URL + two accounts.** Get the real port from the run console; ask for an attacker and a victim test account (local dev login only). - **Enumerate before probing.** A route list (source grep, `/docs`, `/openapi.json`, the router file) turns guessing into checking. - **One class at a time**, minimal request, real request/response as proof, then patch the root cause and replay the exact request to confirm it now fails.skills/paranoid/references/injection.md6.5 KBfd6415f40297upstream ↗
# Injection, SSRF & untrusted input The unifying rule: **never mix untrusted data into a command language by concatenation.** SQL, shell, HTML, file paths, and URLs are all languages an attacker can escape out of if you build strings. Use the structured API instead. ## SQL injection ```js // ✗ db.query(`SELECT * FROM users WHERE email = '${email}'`); db.query("... WHERE id = " + id); // ✓ parameterized db.query('SELECT * FROM users WHERE email = $1', [email]); // pg db.execute('SELECT * FROM users WHERE email = ?', [email]); // mysql2 ``` ```python cur.execute("SELECT * FROM users WHERE email = %s", (email,)) # ✓ cur.execute(f"SELECT * FROM users WHERE email = '{email}'") # ✗ ``` - ORMs (Prisma, Drizzle, SQLAlchemy, ActiveRecord) parameterize for you — **until** you drop to `$queryRawUnsafe`, `.raw()`, `text()` with an f-string, or `find_by_sql`. Those re-open the door; parameterize there too. - Column/table names can't be parameterized — allow-list them against a fixed set, never interpolate user input into identifiers. ## Command injection ```js // ✗ user input in a shell string exec(`convert ${req.file.path} out.png`); // ✓ no shell, argv array execFile('convert', [req.file.path, 'out.png']); ``` ```python subprocess.run(["convert", path, "out.png"]) # ✓ list, no shell subprocess.run(f"convert {path} out.png", shell=True) # ✗ ``` - Avoid `shell=True` / the string form of `exec`. Pass an argv array so the OS never re-parses metacharacters (`; | & $() ` `` ` ``). - `eval`, `Function()`, `exec()`, `pickle.loads`, `yaml.load` (use `safe_load`), and unbounded template engines are all code-execution sinks — never on user input. ## XSS ```jsx // ✗ <div dangerouslySetInnerHTML={{ __html: comment }} /> element.innerHTML = comment; // vanilla <div v-html="comment" /> // vue // ✓ <div>{comment}</div> // React auto-escapes element.textContent = comment; ``` - Default to text rendering; frameworks escape `{expr}` for you — the danger is the explicit "raw HTML" escape hatch. - If you genuinely must render user HTML (rich text), sanitize with **DOMPurify** (client) or a server sanitizer — allow-list tags/attributes, never a blocklist. - Also escape in non-HTML sinks: attribute values, `href`/`src` (block `javascript:`), inline `<script>` JSON (use `JSON.stringify` + escape `<`). - Defense in depth: a `Content-Security-Policy` that forbids inline script turns many XSS bugs into non-events. ## Open redirect A `?next=` (or `return_to`) value that lands in a `Location` header is an open redirect unless you allow-list it. Reject CR/LF too: `urlparse` strips them from the path, so `/dashboard\r\nLocation: https://evil.example` looks like a relative URL while the raw string injects a second header (response splitting). ```python # ✗ return redirect(request.args.get("next") or "/") # ✓ only a same-site relative path, or http(s) on your own host; # reject CR/LF and protocol-relative "//host" ``` ## Path traversal ```js // ✗ ../../../etc/passwd fs.readFile(path.join(UPLOAD_DIR, req.query.name)); // ✓ strip directory components, then confirm containment const name = path.basename(req.query.name); const full = path.resolve(UPLOAD_DIR, name); if (!full.startsWith(path.resolve(UPLOAD_DIR) + path.sep)) throw new Error('bad path'); // symlinks escape a lexical check — resolve the real path when the dir may hold them const real = await fs.promises.realpath(full); if (!real.startsWith(path.resolve(UPLOAD_DIR) + path.sep)) throw new Error('bad path'); ``` - `path.basename` drops `../`, and the `startsWith(resolvedDir + sep)` check rejects absolute paths and lexical `../` escapes. It does **not** catch symlinks: `path.resolve` is a pure string operation that never touches disk, so a symlink *inside* `UPLOAD_DIR` pointing at `/etc` still passes it. When the directory can contain symlinks (a prior upload, an extracted archive), resolve the real path with `fs.realpath` and check containment on that — as the second check above does. (`realpath` needs the file to exist, which a read already requires; for a write target, realpath the *directory* instead.) Never serve files by a raw client path. - For uploads: allow-list extensions/MIME, generate your own filename, store outside the web root, and cap the size. ## Mass assignment ```js // ✗ user can set role, credits, userId… await db.user.update({ where: { id }, data: req.body }); // ✓ parse + allow-list const { name, bio } = updateSchema.parse(req.body); await db.user.update({ where: { id }, data: { name, bio } }); ``` Validate every request body through a schema (zod, pydantic, valibot) at the edge. It gives you allow-listing, type coercion, and length/format bounds in one move — and rejects the 10 MB blob and the negative id for free. ## SSRF (server-side request forgery) When the server fetches a URL the user supplied (link previews, webhooks "test", image import, PDF render): ```js const url = new URL(input); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('scheme'); // resolve DNS and reject private / link-local / loopback targets const { address } = await dns.lookup(url.hostname); if (isPrivate(address)) throw new Error('blocked host'); // 10/8,172.16/12,192.168/16,127/8,169.254/16,100.64/10,::1,fc00::/7 const res = await fetch(url, { redirect: 'manual' }); // don't auto-follow to an internal 302 ``` - The prize is usually cloud metadata (`169.254.169.254`) or internal admin services. Block by resolved IP, not by hostname string (DNS rebinding). - Pin the validated IP at fetch time (or connect to the IP directly with a `Host` header) to eliminate the TOCTOU gap between DNS validation and request execution. - Allow-list hosts if you can (you rarely need to fetch *arbitrary* URLs). - Disable auto-redirects, or re-validate the target on each hop. ## Deserialization & other sinks Untrusted input into `pickle`, Java/Ruby native deserialization, `yaml.load`, XML parsers with external entities (XXE), or regexes built from user input (ReDoS) are all injection cousins. Same rule: parse with a safe, structured loader; never let input choose what code runs. ```python # ✗ nested quantifiers — `'A'*36+'!'` never matches and backtracks exponentially re.match(r'^([A-Za-z0-9]+)+$', username) # ✓ same language, linear in the input length username.isascii() and username.isalnum() # or re.fullmatch(r'[A-Za-z0-9]+', username) ``` Bound the input if you must keep a complex pattern. Don't measure "it was fast on happy-path strings" — that's how ReDoS ships.skills/paranoid/references/secrets-config.md4.5 KB5be46cbdbd6fupstream ↗
# Secrets, config & the client boundary Two failure modes here: secrets that reach the browser, and security decisions made on a device the user controls. Both come from forgetting that **everything shipped to the client is public** — bundles are readable, requests are editable, `localStorage` is inspectable. ## Secrets live server-side, in env, never in code ```ts // ✗ const stripe = new Stripe('sk_live_51J...'); // ✓ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); ``` - Never hardcode keys, tokens, or connection strings. Read from `process.env` / `os.environ` and fail loudly at boot if a required one is missing. - `.env` is **git-ignored**; commit a `.env.example` with empty values instead. - If a secret was ever committed, it is compromised — rotate it. Removing it in a later commit does not help; it's in history. Say this to the user plainly. - Never put a secret in an error message, an API response, or a log line. ### The client-env-prefix trap Bundlers inline any variable with a public prefix into the browser bundle: - Next.js: `NEXT_PUBLIC_*` - Vite: `VITE_*` - Create React App: `REACT_APP_*` - Expo: `EXPO_PUBLIC_*` A `NEXT_PUBLIC_STRIPE_SECRET_KEY` is a public secret — a contradiction that ships constantly. Only publishable/anon keys belong under these prefixes. When you add one, ask: "is it fine for this to be in HTML anyone can view?" If not, it's a server variable and the code that uses it must run on the server. ### The other direction: don't strip the keys that are meant to ship This is the failure mode of being *too* careful, and it is measured, not hypothetical: in this project's own benchmark, the one functional test the paranoid condition failed (and the baseline passed) was a client-config task where it dropped `STRIPE_PUBLISHABLE_KEY` along with the real secrets. The guidance above, followed without this counterweight, filters until the feature breaks. A security fix that breaks payments is not a fix. Some credentials are **designed** to be public and are load-bearing in the browser. Removing them doesn't harden anything — it just breaks checkout, auth, maps, or analytics: | Meant to ship | Never ships | |---|---| | Stripe **publishable** key (`pk_live_…`, `pk_test_…`) | Stripe secret key (`sk_live_…`) | | Supabase **anon** key | Supabase `service_role` key | | Firebase web config, including `apiKey` | Firebase Admin SDK service-account JSON | | Sentry/PostHog **public DSN** or project key | Sentry auth token | | Google Maps **browser** key (restrict by referrer) | Google service-account key | | Algolia **search-only** key | Algolia admin key | The rule: **decide by what the credential is, not by what its name looks like.** `SECRET`, `KEY` and `TOKEN` in a variable name are a prompt to check, not a verdict — `STRIPE_PUBLISHABLE_KEY` and `SESSION_SECRET` both contain one of those words and belong on opposite sides of the boundary. If you can't tell what a credential is, say so and ask, rather than silently dropping it. Firebase's `apiKey` is the classic confusion: it is an identifier, not an authenticator, and Firebase documents it as public. Access is controlled by security rules, so removing the key breaks the app without securing anything. ## The client enforces nothing Anything the client sends can be forged with curl. So: - **Prices, totals, discounts:** the server derives them from ids, never accepts amounts from the body. - **Roles / permissions / `isAdmin`:** read from your datastore by authenticated identity, never from the request. - **Quotas, limits, feature gates:** re-checked server-side even if the UI hides the control. - **Validation:** client-side validation is UX; the server validates again because the client can be bypassed entirely. ```ts // ✗ trusting the client's claim if (req.body.isPremium) unlock(feature); // ✓ server is the source of truth const user = await db.user.findUnique({ where: { id: req.user.id } }); if (user.plan === 'premium') unlock(feature); ``` A useful mental test: **"If the attacker writes the client, does this still hold?"** If not, move the check to the server. ## Config hygiene - Separate keys per environment (dev/staging/prod); a leaked dev key shouldn't touch prod data. - Least privilege on the key itself — a read-only key for a read-only job beats a god key everywhere. - Scan before you push: `git secrets`, `gitleaks`, or `trufflehog` in a pre-commit hook catches the accident before it's permanent. - Debug/verbose flags off in production — they turn into #10 (verbose errors).skills/paranoid/references/vibe-top-10.md5.7 KBa679ab7dd023upstream ↗
# The vibe-coded top 10 The vulnerability classes that AI-generated and "vibe-coded" apps actually ship, ranked by how often they show up. Each entry: **what the agent does wrong**, how to **spot** it, and the **fix pattern**. This is the map — the four topic files (`auth-access`, `secrets-config`, `injection`, `apis-webhooks`) hold the detail. --- ### 1. Broken access control / IDOR The single most common one. The endpoint authenticates the user ("are you logged in?") but never authorizes the *object* ("is this row yours?"). ```js // ✗ any logged-in user can read any invoice app.get('/api/invoices/:id', auth, async (req, res) => { const inv = await db.invoice.findUnique({ where: { id: req.params.id } }); res.json(inv); }); // ✓ scope every lookup to the caller const inv = await db.invoice.findFirst({ where: { id: req.params.id, userId: req.user.id }, }); if (!inv) return res.sendStatus(404); ``` **Spot:** any query keyed on a client-supplied id without the caller's id in the `where`. **Fix:** ownership in the query itself, `404` (not `403`) on miss. → `auth-access.md` ### 2. Missing authentication on endpoints New route ships with no auth because "the frontend never calls it that way." Attackers call it directly. **Spot:** route/handler/server-action with no auth middleware or session check; admin or internal routes guessable by name. **Fix:** deny by default — a shared `requireAuth` wrapper, and public routes are the annotated exception. → `apis-webhooks.md` ### 3. Trusting the client Permission checks, prices, roles, or feature gates enforced only in the browser; the server accepts whatever arrives. ```js // ✗ client sends the price await charge(req.body.amountCents); // ✓ server derives it const price = await priceFor(req.body.sku); ``` **Spot:** `if (user.isAdmin)` in frontend code with no server twin; amounts, roles, or `isAdmin` read from the request body. **Fix:** the server re-derives and re-checks everything that matters. → `secrets-config.md` ### 4. Injection (SQL / command / XSS / path) String-built queries and commands, unescaped HTML, user input in file paths. ```js db.query(`SELECT * FROM users WHERE email = '${email}'`); // ✗ SQLi db.query('SELECT * FROM users WHERE email = $1', [email]); // ✓ element.innerHTML = comment; // ✗ XSS element.textContent = comment; // ✓ fs.readFile(`/data/${req.query.name}`); // ✗ ../../etc/passwd ``` **Spot:** template strings / `+` around SQL, shell, HTML, or paths; `innerHTML`, `dangerouslySetInnerHTML`, `v-html`; `exec`/`eval` with dynamic input. **Fix:** parameterize, use `textContent`, pass argv arrays not shell strings, allow-list and `path.basename` file names. → `injection.md` ### 5. Insecure database / BaaS rules Supabase table with RLS off; Firebase rules `allow read, write: if true`. The API key is public by design, so the rules *are* the security — and they're open. **Spot:** RLS disabled on a table with real data; any rule that resolves to `true`; a service-role key used from the client. **Fix:** RLS on, deny by default, policies scoped to `auth.uid()`. → `auth-access.md` ### 6. SSRF (server fetches a user-supplied URL) Webhook testers, link previews, image proxies, "import from URL." The server fetches attacker-controlled URLs and can be pointed at `169.254.169.254` (cloud metadata) or internal services. **Spot:** `fetch`/`axios`/`requests` where the URL comes from the user. **Fix:** allow-list schemes and hosts, resolve DNS and block private/link-local ranges, disable redirects to them. → `injection.md` ### 7. Leaked secrets API keys hardcoded in source, `.env` committed, keys inlined into the client bundle (`NEXT_PUBLIC_…`, `VITE_…`), or secrets echoed in error responses/logs. **Spot:** high-entropy strings in source; `.env` tracked by git; secrets under a client-exposed env prefix; keys in log lines. **Fix:** env only, server only, rotate anything that was committed, `.gitignore` the env file. → `secrets-config.md` ### 8. Mass assignment / missing validation The whole request body is trusted and spread into a DB update, letting a user set fields they shouldn't (`role`, `isAdmin`, `credits`, `userId`). ```js await db.user.update({ where: { id }, data: req.body }); // ✗ sets role too ``` **Spot:** `...req.body`, `Object.assign(record, req.body)`, `data: req.body`. **Fix:** parse the body through a schema (zod/pydantic) and pass only the allow-listed fields. → `injection.md` ### 9. Unverified webhooks & no rate limiting Payment/CI webhooks whose signature is never checked (anyone can POST a fake "payment succeeded"); auth and reset endpoints with no throttle (credential stuffing, enumeration). **Spot:** a webhook handler reading `req.body` with no signature verification; login/OTP/reset routes with no rate limit. **Fix:** verify the provider signature against the raw body; rate-limit sensitive routes. → `apis-webhooks.md` ### 10. Verbose errors, PII logs, permissive CORS Stack traces and SQL errors returned to the client; emails/tokens written to logs; `Access-Control-Allow-Origin: *` alongside credentials; missing security headers. **Spot:** `res.send(err)` / `err.stack` to client; PII in `console.log`; CORS reflecting any origin with `credentials: true`. **Fix:** generic client errors + server-side detail, redact logs, CORS allow-list, set the standard headers. → `apis-webhooks.md` --- **Why these ten:** they line up with OWASP's top categories (broken access control and injection lead there too), and with what security benchmarks find LLMs produce — functionally correct backends that are still exploitable about half the time. They are deliberately concrete: an agent can check for every one of them mechanically.skills/paranoid/checklists/pre-commit.md1.6 KBc1bf3af4755dupstream ↗
# Pre-commit security gate Run this before committing a feature that crossed any trust boundary. Seven questions. If code changed touches auth, data access, input, secrets, outbound requests, or endpoints and you can't answer "yes" (or "n/a"), fix it first. Report the result compactly, e.g.: > paranoid gate: 1 ✓ · 2 ✓ · 3 ✓ · 4 ⚠ fixed (added ownership check) · 5 ✓ · 6 n/a · 7 ✓ 1. **Access control** — Does every lookup/mutation on a client-supplied id scope to the authenticated caller (ownership/membership, not just "logged in")? 2. **Auth coverage** — Is every new route/handler/action authenticated and authorized by default, with any public one a deliberate, marked exception? 3. **Client trust** — Are all prices, roles, permissions, and gates enforced on the server? No secret or security decision shipped to the browser? 4. **Injection** — Is all SQL/shell/HTML/path/URL construction parameterized, escaped, or allow-listed — no string-built queries, `innerHTML`, or `shell=True` on user input? 5. **Validation** — Does every request body/query pass through a schema with field allow-listing and size/length/range bounds (no `data: req.body`)? 6. **Untrusted I/O** — Are webhooks signature-verified, sensitive routes rate-limited, and server-side fetches of user URLs SSRF-guarded? 7. **Leakage** — Errors generic to clients, no secrets/PII in logs or responses, CORS an allow-list, security headers set, responses free of extra DB fields? If the app is running locally and the user wants proof rather than a checklist, escalate to the **hack-me** workflow (`commands/hack-me.md`).README.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/kulchankas-paranoid-skills-paranoid.md