AGENT SKILL

auth-review

Reviews authentication and authorization in any codebase — login flows, password storage, session/cookie handling, JWT pitfalls, OAuth/OIDC misconfigurations, IDOR/broken access control, privilege escalation, CSRF protection, route census for unguarded endpoints, trusted-header identity spoofing, check-then-act race conditions (TOCTOU), and WebSocket/SSE authorization. Use when auditing how users log in, how permissions are enforced, how internal services trust each other, or when reviewing auth-related code changes.

License 许可
MIT
最近更新
2026-11-14
Stars
★ 81
Forks
17

01 前言 Front Matter

name
auth-review
license
MIT
description
Reviews authentication and authorization in any codebase — login flows, password storage, session/cookie handling, JWT pitfalls, OAuth/OIDC misconfigurations, IDOR/broken access control, privilege escalation, CSRF protection, route census for unguarded endpoints, trusted-header identity spoofing, check-then-act race conditions (TOCTOU), and WebSocket/SSE authorization. Use when auditing how users log in, how permissions are enforced, how internal services trust each other, or when reviewing auth-related code changes.

02 资源文件 3

03 SKILL.md 正文

Authentication & Authorization Review

1 — Map the auth architecture first

Find: login/register/password-reset endpoints, session middleware, role/permission checks, token creation/validation, OAuth client configs. Write a 3-line model: who can authenticate how → what identity looks like → where authorization is enforced. Broken access control is much easier to spot once you know the intended model.

Route census (enumerate, don't guess)

Pattern greps find what they look for; a census finds what nobody remembered. Enumerate EVERY route, then check each one:

rg -n "app\.(get|post|put|patch|delete|all|use)\("            # Express/Koa
rg -n "@app\.route\(|@\w+\.route\(|add_url_rule"              # Flask
rg -n "path\(|re_path\(" */urls.py                             # Django
rg -n "^\s*(get|post|patch|put|delete) |resources " config/routes.rb  # Rails
rg -n "@(Get|Post|Put|Patch|Delete|Request)Mapping"             # Spring
rg -n "Route::(get|post|any|resource|apiResource)" routes/     # Laravel
rg -n "@(app|router)\.(get|post|put|delete|patch)"             # FastAPI

Build a table route → auth middleware → ownership check and fill it by reading each handler. Every row with neither is a finding (Critical for admin/state-changing, High otherwise). Census-only catches:

  • routes registered BEFORE the auth middleware (order bugs — app.use(auth) must precede every guarded route)
  • the one handler missing @login_required / authorize / auth:sanctum in an otherwise-guarded file
  • non-HTTP surfaces that are routes in disguise: queue consumers, cron/background job handlers, webhook receivers, GraphQL resolvers (see ../graphql-security/SKILL.md), event subscribers, RPC/message handlers — census them with the same table
  • dispatch-style apps (?action= → switch) — enumerate the switch arms, not the single URL
  • case-sensitive middleware paths: on case-insensitive hosts/routers, app.use('/admin', guard) can be bypassed by /ADMIN or /Admin/ — check the router's case-sensitivity setting (Express: default sensitive only with regex; some frameworks/filesystems are not) and whether any guarded prefix can be cased around → authz bypass
  • encoding-sensitive middleware paths (CWE-177): authz guards comparing the RAW, still-encoded request URI (req.url / req.originalUrl, Java getRequestURI(), RAW_URI) while the router dispatches on the DECODED path — GET /%61dmin/users fails startsWith('/admin') yet still reaches the admin route → unauthenticated admin access (class incident: Cisco Catalyst SD-WAN Manager CVE-2026-76504, KEV). Safe shape: compare the decoded path the router actually dispatches on (req.path, Java getServletPath()), or mount the guard on the route itself (app.use('/admin', guard)), so percent-encoding can't split the check from the dispatch:
rg -n "req\.(url|originalUrl)\.(startsWith|endsWith|includes|match)|getRequestURI\(\)\.(startsWith|contains|equals)|RAW_URI" -g '*.js' -g '*.ts' -g '*.java' -g '*.py'
  • suffix/contains path matching in auth whitelists (third variant of the path-matching family: case → encoding → MATCHER STRICTNESS): a public-path exemption checked with path.endsWith('/x') or path.contains('/x') instead of an exact/anchored match frees EVERY colliding route, not just the intended public one (class incident: Kestra CVE-2026-49869, KEV — AuthenticationFilter used request.getPath().endsWith("/configs") to whitelist the public configs endpoint; every namespace-scoped .../configs route bypassed Basic Auth → unauthenticated API access → RCE). Safe shape: exact match set (new Set(['/configs']).has(path)) or anchored regex (^/configs$) — never suffix/substring tests on a path:
rg -n "endsWith\(\s*['\"]\/|\.contains\(\s*['\"]\/" src/ -g '*.java' -g '*.js' -g '*.ts' -g '*.py' | head
  • setup/install/first-run wizard routes: app.post('/setup/restore'), /install, /api/setup/* are unauthenticated BY DESIGN during first boot — one that stays reachable after initialization is an auth-bypass front door (class incident: ground-station CVE-2026-103244 — setup.restore invoked over Socket.IO during setup mode planted admin users and forged session tokens; full takeover). Census them with the route table; every setup route must verify installation state server-side and refuse once installed:
rg -n -i "['\"]/(setup|install|installer)[^'\"]*['\"]" -g '*.js' -g '*.ts' -g '*.py' -g '*.java' -g '*.rb' -g '*.php' | head -15
  • gRPC / protobuf services: service X { rpc Y (...) } in .proto files — every rpc is a route; check per-method auth interceptors and validate request fields like any handler. Same for message-queue consumers (Kafka/Rabbit/SQS handlers), scheduled job entry points, and Netty/WebSocket frame handlers — add them all to the census table
rg -n "rpc \w+\(" -g '*.proto'; rg -n "@GrpcClient|StreamObserver" -g '*.java' -g '*.kt'
rg -n "@(KafkaListener|RabbitListener)|consumer\.|@Scheduled|@CloudFunction|functions\." -g '*.java' -g '*.kt' -g '*.py' -g '*.js' | head

2 — Authentication checks

Password storage

rg -n -i "bcrypt|argon2|scrypt|pbkdf2|md5|sha1|sha256.*password|hashpw|crypt\("
  • bcrypt/argon2id/pbkdf2 with sane params (bcrypt cost ≥ 10, argon2id defaults or better) → OK
  • MD5/SHA1/SHA-256 for passwords → CRITICAL (unsalted fast hash = instant crack)
  • Plaintext or reversible encryption → CRITICAL

Login mechanics

  • User enumeration: login and password-reset responses differ for unknown vs wrong-password (timing or body) → MEDIUM
  • No rate limiting / lockout on login, reset, and MFA endpoints → HIGH (note where it would live: middleware, API gateway, app code)
  • Password reset: predictable token (timestamp, userid, random.random()), token reused or non-expiring, reset response leaks the token → HIGH/CRITICAL
  • MFA present? Missing MFA on admin accounts → HIGH if roles exist

LDAP authentication (enterprise directories)

rg -n "SECURITY_AUTHENTICATION|ANONYMOUS" -g '*.py' -g '*.java'
rg -n 'uid="\s*\+\s*\w+|SECURITY_PRINCIPAL.*\+' -g '*.py' -g '*.java'
rg -n '\(sAMAccountName=|\(uid=' -g '*.py' -g '*.java'
rg -n "escape_dn_chars|escape_filter_chars|LdapEncoder" -g '*.py' -g '*.java'
  • Anonymous bind offered as an auth path (authentication=ANONYMOUS, SECURITY_AUTHENTICATION set to none) → CRITICAL — everyone "authenticates"; binding ≠ verifying the intended identity
  • DN injection (CWE-90): bind DN built by concatenation — "uid=" + user + ",ou=people,..." (Python) or Context.SECURITY_PRINCIPAL concatenation (Java) → CRITICAL: a uid containing ,... or + rebinds as a different principal
  • Filter injection: search filters composed by f-string/format with user terms (f"(sAMAccountName={name})") → HIGH — * and )( characters forge wildcard/boolean filters (auth bypass + data mining)
  • Safe shape: escape before composing (escape_dn_chars() / escape_filter_chars() in ldap3, LdapEncoder.filterEncode/nameEscape in Java) — or constant service DN + post-bind attribute comparison
  • The DN/filter greps hit BOTH vulnerable and escaped forms — the escape-call context is the triage discriminator (same census discipline as XSS sinks)

Profile changes → account takeover

Email/password-change endpoints are AUTH endpoints — a session alone is not sufficient proof (stolen session, XSS-riding, insider):

rg -n "email\s*=|updateEmail|changeEmail|current_password|currentPassword|change.?password" src/ app/ | head -20
  • Email change without current-password confirmation AND without re-verification of the NEW address (verify-before-swap + notice to the old one) → High — a hijacked session silently owns the account: future password resets go to the attacker's address, no signal ever reaches the victim
  • Password change without current password → same class, High
  • Username/handle change that re-frees an old identifier while reset-by-username flows exist → Medium
  • Safe shape: requireCurrentPassword (or step-up re-auth) + verification mail to the new address before activation + revocation notice to the old one

JWT / tokens

rg -n "jwt\.(sign|decode|verify)|verify\(|algorithms|algorithm|none|HS256|RS256"
  • algorithm: "none" accepted or jwt.decode(token) without algorithm pinning → CRITICAL
  • Algorithm confusion (RS256 → HS256): jwt.verify(token, PUBLIC_KEY, {algorithms: ['HS256', ...]}) — attacker signs with the public key as HMAC secret → CRITICAL. Pin asymmetric algorithms only when the key is public; never pass a public key where the library accepts it for HMAC.
rg -n "algorithms.*HS256|verify\(.*public|createPublicKey|jwt\.RSAPublicKey" 
  • JWKS kid injection: token-controlled kid/alg/jku/x5u header flowing into paths, queries, or fetches ('./keys/' + header.kid, jwks_url + kid) → path traversal / attacker-controlled key material → Critical. Trust kid for lookup only against a fixed key set.
rg -n "header\.kid|\bkid\b.*\+|jku|x5u" 
  • PKCE: public clients (SPA/mobile/desktop — anything that can't hold a secret) using auth-code flow without code_challenge/code_verifier → code interception, HIGH. Server-side confidential clients may omit it (note, not a finding).
  • Symmetric signing with weak/default secret (secret, jwt-secret, changeme) → CRITICAL
  • No exp validation, or no aud/iss checks where multiple services share keys → HIGH
  • Tokens in localStorage with XSS present elsewhere → MEDIUM (compounding)
  • Session tokens: not rotated on login/privilege change → MEDIUM; long-lived non-revocable tokens → HIGH

3 — Authorization checks (IDOR & friends)

Find object access points, then check each for an ownership/scope test:

rg -n "req\.params\.id|findById\(|get\(|\.filter\(.*userId|user\.id|currentUser|req\.user"
  • Model.findById(req.params.id) returned directly with no where userId / post-fetch ownership check → IDOR, HIGH (CRITICAL for sensitive objects: invoices, messages, PII)
  • Association (chained) IDOR — downstream handlers fetch by a related id (?order_id=, payment_id) without verifying ownership THROUGH the join: each endpoint guards its own object, the chain leaks. Systematic method in ../flow-security/SKILL.md (class F3) — audit flows, not just routes |
  • Admin routes: how are they guarded? Missing middleware → CRITICAL. Note function-level checks too (regular user hitting /admin/* handlers).
  • Horizontal vs vertical: test reasoning for both — same-role users touching each other's data, and low-role → admin.
  • Trusting client-side hints: is_admin from request body/cookie-in-JWT-without-verify, hidden-but-served admin UI → HIGH
  • authz on derived/divergent state (ToB, Provenance 2026): when a permission decision reads a DERIVED value (cached role, denormalized count/balance, materialized flag) instead of the live source-of-truth record, two failure modes: (a) stale/replicated state authorizes what live state would deny, and (b) missing/zero/default derived state must FAIL CLOSED — a check like if derived_supply > 0: grant admin grants when the derived value is empty. Trace every authz read to its source-of-truth write; verify the default branch denies.

Mass assignment (generic)

The framework skills cover Laravel/Django/Rails fillable semantics; the generic version bites every ORM:

rg -n "\.create\(\s*req\.body|\.update\(\s*req\.body|Object\.assign\(\s*\w+\s*,\s*req\.body|new \w+\(\s*req\.body|findOneAndUpdate\(\s*\w+,\s*req\.body|bulkCreate\(\s*req\.body"

Request body spread into create/update without an allowlist → attacker sets role, isAdmin, userId, credits, price, email (account takeover via email change). Report High — Critical when the model has role/permission fields. Safe: explicit field destructuring/pick, schema-level allowlist ($fillable, fields), validators configured to strip unknown keys (default validate/zod .strict() — not passthrough). Also check filter/map "sanitizers" that only check types: an allowLIST of keys, not of types, is what counts.

Trusted-header authorization (identity spoofing)

rg -n -i "req\.headers\[\s*['\"]x-|request\.headers\.get\(|getHeader\(\s*\"X-|headers\[\s*'X-|x-forwarded-for|x-forwarded-user|x-real-ip|HTTP_X_"

Using X-User-Id / X-Email / X-Admin / X-Forwarded-For as the identity or authz input means anyone who can reach the service directly is any user → CRITICAL. Valid only when BOTH hold: (1) an edge proxy/gateway strips these headers from inbound traffic, and (2) the app is not directly reachable (check NodePort/LoadBalancer services, ingress annotations, port exposure in compose). Microservice meshes are the classic miss: service B trusts X-User-Id "because only service A calls us" — but anything on the cluster network can. Headers used only for logging (X-Request-Id) are fine — trace the value into an authz decision before flagging.

SSO/OIDC callback host verification (CWE-943). Rocket.Chat GHSL-2026-004/005: an account/SSO service accepted callbacks whose host did not match the configured/allowed one → authentication bypass. Wherever an OIDC/OAuth/SAML/OmniAuth callback or JWKS/discovery URL is resolved from configuration, verify the code checks the exact host/origin (allowlist, not substring/endsWith) and rejects mismatches before trusting the identity assertion → Critical (direct auth bypass).

Authorization predicate on a request-derived principal (sibling of header spoofing — the write side). The capability/role check itself is evaluated against a user object CONSTRUCTED FROM REQUEST DATA instead of the authenticated session principal: user_can(get_user_by('id', $_COOKIE['original_user_id'])), findById(req.cookies.user_id).can(...), hasRole(req.body.role). The check "passes" for whoever the REQUEST names — forging the cookie/param IS the privilege. DevKit Pro CVE-2026-14378 shape: user-switching revert checked manage_options on the cookie-named user instead of current_user_can() → admin takeover. → Critical. The principal for any authz decision must come from the session/token verification path; request data may name a TARGET, never the ACTOR.

rg -n "findById\(\s*req\.(cookies|body|query)|get_user_by\([^)]*\$_(COOKIE|GET|POST|REQUEST)|user_can\s*\(\s*\$" src/ app/ -g '*.js' -g '*.php'

Multi-tenant scoping census (SaaS)

Per-object IDOR checks miss the systematic version: in a multi-tenant app, EVERY data access must carry the tenant filter — one query without it leaks a whole tenant's data to another tenant's users (often via list/export/report endpoints that feel "shared").

rg -n "tenant|org_?id|account_?id|customer_?id|workspace" -g '*.js' -g '*.ts' -g '*.py' -g '*.java' -g '*.rb' -g '*.php'   # tenant model census — size it (wc -l), then read
rg -n "\.(find|where|filter|all|select|query|get)(All)?\(" -g '*.js' -g '*.py' | rg -v "tenant|org_|account_|customer_|user"   # unscoped reads — census: disposition EVERY line

Method: list every model access, mark each SCOPED (tenant filter present) / UNSCOPED / GLOBAL-BY-DESIGN (shared catalog). Every UNSCOPED access to tenant-owned data → Critical. Stronger defenses to note when present: DB-level row-level security (RLS), ORM global scopes (Laravel global scope, Django manager), middleware-injected tenant context that queries MUST use. Also check: tenant taken from request body/header instead of session (req.body.tenantId — attacker-controlled scope switch → cross-tenant, Critical), and cache keys missing the tenant prefix (cross-tenant cache bleed).

  • Target-tenant vs caller-tenant on credential-issuing endpoints: when a request names a TARGET (user_id, email, login_name) and the handler issues enrollment codes / invitations / reset URLs / passkey or passwordless registrations for that target, checking only the CALLER's request tenant (org header, subdomain) while never comparing the TARGET's tenant = cross-tenant takeover: an admin (or any user-write holder) in org A obtains enrollment credentials for org-B users and registers their own authenticator (class incident: ZITADEL CVE-2026-105209 — passkey enrollment codes authorized against x-zitadel-orgid while the target user's organization went unchecked, cross-org ATO, CVSS 9.6). The authz must compare the TARGET's tenant with the caller's tenant (or verify the caller's scope covers the target's tenant) — validating the request's own tenant header proves nothing about the target. Strongest shape: scope the target lookup itself by the caller's session tenant (findOne({ id, org_id: session.org })) rather than fetch-then-check:
rg -n -i "enroll(ment)?.?code|invite.?(code|user)|passwordless" src/ app/ -g '*.js' -g '*.ts' -g '*.py' -g '*.java' | head

Check-then-act races (TOCTOU)

Look for state checks followed by a separate write:

rg -n "if\s*\(.*\b(used|redeemed|approved|active|enabled|stock|balance|remaining)\b|\.findOne\(.*\)\.then|SELECT.*\b(balance|stock|used)\b.*FROM|\.save\(\)"
  • Single-use coupons/tokens/reset links: if (t.used) reject; ... t.used = true → parallel replay wins every time → High (Critical for payments/refunds/webhooks). Fix: atomic conditional write — findOneAndUpdate({code, used:false}, {$set:{used:true}}), UPDATE ... SET used=1 WHERE code=? AND used=0 checking rows affected.
  • Balance/stock read-modify-write: user.balance -= amt; user.save() → double-spend under concurrency. Fix: UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?.
  • Webhooks/event handlers without idempotency keys or event-id dedup → replayable. Report as one grouped finding with every affected flow listed.

Feature-flag & plan-capability gating

Flags constrain CAPABILITY (what a plan/tenant/role may do), not identity — an authenticated admin can still be the attacker the plan must stop. UI/menu gating is cosmetic; the handler/route is the enforcement point. Incident class (2026-09-23): a trial tenant blasted 1200+ emails through a compose route whose flag was enforced only in the menu and the queued job.

rg -n "Feature::(define|active)|isFeatureEnabled|featureFlags|feature_flags|isEnabled\(|isEnabledFor|\.variation\(" src/ app/ modules/
rg -n "pennant:purge|flushCache|flag.*purge" deploy/ scripts/ .github/

Stack-agnostic census (framework skills carry the concrete greps — e.g. ../laravel-security/SKILL.md Step 8):

  • For every flag whose name implies a dangerous capability (import, email, send, sms, export, invite, admin, billing): WHERE is it enforced? Checks only in UI/menu code or only inside async jobs = gated-in-the-wrong-layer → High; Critical when the ungated surface fans out outbound messages (F9, ../flow-security/SKILL.md)
  • Default-true definitions for dangerous capabilities ('import': true, "send_email": true in flag config) → High — the capability exists for everyone including trials; per-plan closures/lookups are the safe shape
  • Flag-store staleness: persisted flag stores (Pennant database, DB-backed Unleash/OpenFeature, a features table) keep stored per-scope values when defaults change — reverting a default changes nothing for already-stored scopes. Deploy scripts must purge/sync; absence → Medium
  • Public endpoints that TRIGGER OUTBOUND messages (send-verification, subscribe, reset, notify, magic-link, webhook-register): census them with §1's route table; each needs auth-or-signed + throttle (+ captcha where public) → Critical when public AND unthrottled: ID enumeration = mail/SMS bomb needing zero auth and zero flags

One-time codes (verification / login / MFA codes)

A one-time code IS a bearer credential — census every endpoint that PRODUCES or VERIFIES one, then check WHERE the code goes and WHAT ELSE the verifier accepts:

rg -n -i "verification.?code|login.?code|one[-_ ]?time.?code|\botp\b|magic.?code|sms.?code|email.?code" src/ app/ routes/ | head -40
rg -n -i "(master|static|backdoor|debug|test).{0,12}code" src/ app/ config/ db/ seeds/ -g '*.sql' -g '*.java' -g '*.py' -g '*.js' -g '*.ts' -g '*.yml'
  • Producer endpoint whose HTTP RESPONSE carries the generated code (res.json({ ..., code }), put("code", ...), response wrapper, template rendering it) → Critical — unauthenticated takeover of any known identifier: request the code, read it from the response, log in (class incident: CVE-2026-97063, X-SpringBoot — GET /sys/mobile/code and /sys/email/code returned codes; login via /sys/emailOrMobileLogin)
  • Static/master code the verifier accepts (seed rows, config constants, "test-mode" fallbacks — X-SpringBoot's 172839 seed, CVE-2026-97064; entry: ../cve-research/vuln-db/entries/2026-09-25-cve-2026-97064.md) → Critical — a public backdoor credential; if it ever shipped, assume it IS used
  • Code delivered to an address/number taken from the REQUEST instead of the stored account record → High — attacker-controlled delivery channel harvests codes
  • Weak hygiene: reusable codes, no expiry, no attempt cap on verify, predictable generators (Math.random) → Medium→High by brute-force feasibility
  • Safe shape: CSPRNG code, sent ONLY to the stored channel, response = {sent: true} (existence-neutral), single-use + short TTL + verify throttle + constant-time compare, NO static fallback in any environment — dev seeds get cloned to prod

Webhook receiver authentication

Webhook endpoints are authenticated by the SENDER's signature, not by your sessions — an unverified receiver is an unauthenticated endpoint with money/entitlement powers. Census every webhook/callback route with §1's table, then:

rg -n "webhook|callback" src/ app/ routes/ | rg -i "post|route" | head -20
rg -n "timingSafeEqual|compare_digest|verify_sig|verifySignature|signature" src/ app/
rg -n "!process\.env\.[A-Z_]*SECRET|WEBHOOK_SECRET" src/ app/ .env.example 2>/dev/null
  • Handler parses req.body/event and ACTS (fulfill, enroll, activate, refund) without verifying the sender's HMAC signature → Critical (forged events = free entitlements; pairs with F4 replay in ../flow-security/SKILL.md)
  • Signature compared with ==/=== instead of constant-time (timingSafeEqual, hmac.compare_digest) → Medium (timing oracle)
  • Signature computed over re-parsed JSON instead of the RAW request bytes → verification fails → commonly "fixed" by deleting the check (drift toward Critical)
  • Verification gated on the secret being SET (if (!WEBHOOK_SECRET) next() / empty-secret guard that skips the check) → fail-open on missing config → Critical — the Dockhand CVE-2026-53988 shape: null webhook secret guard → unauthenticated stack redeploy, and the redeploy pulls an attacker-controlled docker-compose.yml (privileged bind mounts) = container escape. A missing/empty secret must REJECT (fail closed), never skip verification.
  • No replay window (timestamp/event-id dedup) → High
  • Callback that ESTABLISHES a session (not merely acts): the handler authenticates the CALLER from the payload — wp_set_auth_cookie()/wp_set_current_user() on a request-supplied user id, req.login(user) where user came from a GET/POST param (base64 blob, JSON field). There is no signature to fall back on: identity itself arrives from the attacker, so a forged callback = logged in as anyone → Critical. Divi Membership CVE-2026-19660 shape: process_paypal_callback on init, base64 paypal_param GET param, no IPN/signature/ownership/nonce → wp_set_auth_cookie(). Census every session-establishment call and trace where its user comes from:
rg -n "wp_set_auth_cookie\s*\(|wp_set_current_user\s*\(|req\.login\s*\(|\.logIn\s*\(" src/ app/ -g '*.js' -g '*.ts' -g '*.php'
  • Safe shape: the callback verifies the sender FIRST and resolves the member/user from the VERIFIED event's stored record (order → user), never from a request parameter; raw-body HMAC + constant-time compare + event-id dedup, all before any side effect

API keys as authentication (service-to-service)

Machine auth has its own failure modes — check every API-key/gateway-token scheme:

rg -n -i "x-api-key|api[_-]?key" -g '*.js' -g '*.ts' -g '*.py' -g '*.java' -g '*.go' -g '*.rb' | head
  • Key in URL/query (?api_key=) — hits logs, referrers, browser history → HIGH (same class as tokens in URLs)
  • Unscoped keys — one god-key instead of per-service/per-permission keys: lateral movement on any leak → MEDIUM/HIGH
  • No rotation story — no expiry, no revocation endpoint, keys older than the repo's history → MEDIUM (operational)
  • Constant-time comparison absent (== on the key) → timing oracle → LOW/MEDIUM (pairs with crypto-review comparison rules)
  • Weak key generation — uuid(), random.random(), timestamp-derived → predictable keys → HIGH (see crypto-review randomness table)
  • Key = password reuse — same key validates AND encrypts/signs → separation of duties violation → MEDIUM
  • Non-secret identifiers as credentials — authenticating a device/user by a value that is public or enumerable (hostname, hardware serial, username, email, machine-id) is authentication theater: anyone who knows the identifier IS the identity (class incident: Fleet CVE-2026-103264 — device API accepted hostnames and hardware serials as tokens alongside UUIDs; unauthenticated actors became any iOS/iPadOS host, MDM migration included). Identifiers are for LOOKUP, never for proof — enroll with a one-time secret or client cert, then authenticate on that:
rg -n -i "findby(hostname|serial|hardware)|by_(hostname|serial)|token\s*={2,3}[^=]*(hostname|serial|machine.?id)" -g '*.js' -g '*.ts' -g '*.py' -g '*.go' -g '*.java'
  • Internal keys honored as master auth on every route (UTMStack CVE-2026-82042 class: InternalApiKeyFilter accepted the Utm-Internal-Key header == INTERNAL_KEY env var for ANY endpoint — no path restriction, no constant-time compare, no rate limit, no audit logging — so anyone who obtains the infra key gets full admin API: account creation, user management, rule changes): an internal/service perimeter key that the PUBLIC surface also accepts is a god-key with extra steps — the perimeter that justified the key does not exist at the app. The key must (1) be honored only on internal routes/hosts, (2) compare constant-time, (3) grant a SCOPED service identity (never admin), and (4) be obtainable only by internal callers — if any less-privileged endpoint reveals it, the scheme is Critical regardless of the rest:
rg -n -i "internal[_-]?key|x-internal|internal[_-]?api[_-]?key" src/ app/ -g '*.js' -g '*.ts' -g '*.py' -g '*.java'

4 — Session & CSRF

  • Cookie flags on session/auth cookies: HttpOnly (missing + XSS present → HIGH), Secure (missing → HIGH if prod is HTTPS), SameSite (missing → MEDIUM)
  • CSRF: state-changing endpoints protected by token/SameSite=Strict/Origin check? Both auth-cookie AND no CSRF defense → HIGH
  • Logout invalidates server-side session? Not just client cookie clear → MEDIUM
  • Session fixation: session ID regenerated after login → check

5 — OAuth/OIDC, if present

  • redirect_uri validated exactly (not prefix/suffix match) → open redirect_uri = account takeover, CRITICAL
  • state parameter present and verified → missing = CSRF/login CSRF, MEDIUM/HIGH
  • Token in URL fragment vs query (query leaks via logs/referrers) → MEDIUM
  • id_token validated: signature, nonce, aud → skipping any = CRITICAL
  • Account linking (external IdP identity → local account) without caller verification: census every surface that BINDS an external-IdP identity to a local account (AddIDPLink, /auth/link, link_account, social connect). A linking call that acts on a caller-SUPPLIED login name / user identifier — without an authenticated session plus a fresh primary factor or proof of control of BOTH identities — lets anyone who knows a victim's login name attach their OWN IdP identity and then sign in as the victim (class incident: ZITADEL CVE-2026-105207 — User Service V2 AddIDPLink + identify-only Login V2 sessions, unauthenticated ATO, CVSS 9.8). → Critical. Safe shape: link only to the session principal, require step-up re-auth, and take the external identity from the IdP's verified assertion (never from request fields):
rg -n -i "add_?idp|idp.?link|link.?(account|identity)|connect.?(account|identity)" src/ app/ -g '*.js' -g '*.ts' -g '*.py' -g '*.java' -g '*.go' -g '*.php' | head -20

Host-header poisoning in outbound auth/email flows

Password-reset/verification links and OAuth redirect URIs built from Host / X-Forwarded-Host (request-derived) let an attacker poison the victim's link domain (CodeQL class; Django's ALLOWED_HOSTS=['*'] is the classic enabler — see ../django-security/SKILL.md Step 1). Grep link/URL construction in mailers and auth controllers for request-header inputs → HIGH (account takeover via poisoned reset links).

6 — SAML (if present)

rg -n -i "saml|assertion|NameID|acs[ _-]?url|AudienceRecipient|samlp" -g '*.py' -g '*.java' -g '*.rb' -g '*.php' -g '*.js' -g '*.xml' | head -15

The four classic SAML bugs, in observed frequency order:

  • Signature wrapping (SWA/XSW): the response's Signature element covers one assertion but the app reads a DIFFERENT one (attacker-injected). Defense: validate the signature over the exact node you consume (response.get_assertion().validate_signature? no — one_assertion_only + signature-verified read). Grep for one_assertion_only / want_assertions_signed settings — absent → HIGH/Critical.
  • Comment injection in NameID: admin@corp.com<!--atk-->@evil.com parses as admin@corp.com to the IdP-signed value but the SP extracts differently — mitigated by library versions and NameID format checks; flag any custom NameID string handling → HIGH.
  • Unvalidated recipients: Destination/AudienceRestriction/ACS URL not enforced (or ACS taken from the assertion!) → assertion replay across SPs. Config keys: expected_audience, acs_url pinned server-side → absent → Critical.
  • Loose validation knobs: want_response_signed=False + want_assertions_signed=False, clock skew ±24h (not_before/not_on_or_after neutered), allow_unsolicited in prod → each HIGH.

Safe shapes: pinned ACS + audience, both signed flags true, one-assertion-only, tight skew (≤5m), library current (python3-saml / OneLogin / passport-saml families each had historic CVEs — version-check via vuln-db/OSV).

7 — Realtime channels (WebSocket / SSE)

rg -n "io\.on\(|socket\.on\(|new WebSocket|WebSocketServer|@MessageMapping|@SubscribeMapping|STOMP|EventSource|ActionCable|cable\.|channel\.subscribe"
  • Handshake auth: no io.use(authMiddleware) / token check on connect → CRITICAL (any client connects as anyone)
  • Subscription authz: socket.join('room:' + id) / channel subscribe without ownership check → IDOR over sockets, High — same rule as REST IDOR, different transport
  • Message handlers are routes: every socket.on('cmd', ...) doing a state change needs authz; grep the handler bodies exactly like controllers
  • Command-forwarding handlers are RCE surfaces (UTMStack CVE-2026-82041 class: STOMP /command/{hostname} destination → processCommand() forwarded arbitrary OS commands over gRPC to agents, no role check, no allowlist — any authenticated user, any role, root/SYSTEM on monitored hosts): a realtime channel whose messages become COMMANDS executed on backends/agents must enforce (1) role check per handler — authentication ≠ authorization, "any logged-in user" is not a policy — and (2) a server-side command allowlist (verb + target scope) before anything reaches the executor. Generic shape: message destination → agent.send({cmd}), grpc.invoke, exec(/spawn( inside the handler → Critical (remote command execution on downstream hosts):
rg -n "socket\.on\(\s*['\"][^'\"]*(command|cmd|exec|run)|@MessageMapping\(\s*['\"][^'\"]*(command|cmd|exec)" src/ app/ -g '*.js' -g '*.ts' -g '*.java'
rg -n "(agent|host)\.(send|execute|invoke)\(" src/ app/ | head
  • io.origins('*') / missing origin check on a credentialed socket → Medium (cross-site WebSocket hijacking)
  • SSE (EventSource endpoints) are plain GETs — same authz as any other route

Reporting

For each finding: severity, file:line, one-line exploit story ("authenticated user A reads user B's invoice by changing ?id="), and the concrete fix (ownership scope in query, middleware, flag). Group IDORs by endpoint pattern — they usually cluster.

04 同仓库更多 Skills