audit-redirects
Find open redirects in pracht loaders, middleware, and navigation. The framework rejects unsafe schemes and CRLF at both ends, but a hand-rolled 3xx Response bypasses every guard, and even a guarded redirect to an attacker-chosen origin can phish. Use for "audit redirects", "chec
Install
npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-redirects
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
git clone https://github.com/JoviDeCroock/pracht.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Pracht Audit Redirects
The classic open-redirect bug: /login?redirect=https://evil.example →
after login, the app redirects the user to attacker territory carrying a fresh
session.
What the framework guarantees: the client router drops non-http(s): schemes
at the navigation boundary, and server-side redirect() /
buildRedirectResponse() reject non-http(s): schemes and CR/LF injection
against the Location header. What it cannot decide is whether the target
origin or path is one you trust — and a raw
new Response(null, { status: 302, headers: { location: userInput } })
bypasses all of those guards entirely.
Prerequisites: pracht inspect requires a vite config that registers the
pracht plugin.
Step 1: Inventory redirect sites
Bound the search first — do not grep the whole repo blind:
pracht inspect routes --json
MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its
inspect_routes/inspect_api/inspect_build/doctor/verify tools over
shelling out.
The resolved graph gives you each route's file, loaderFile, and
middleware names (resolve names to files via the defineApp({ middleware })
map in the manifest). Grep those files plus the API handler files from
pracht inspect api --json, and client components, for two distinct classes:
Class A — guarded sites (scheme/CRLF-safe, still open-redirect capable):
- Middleware/loaders/handlers returning
redirect(...)orbuildRedirectResponse(...). useNavigate()(value)and<a href={value}>wherevalueis dynamic (client guard applies).prefetchRouteState(value)calls with dynamic input.
Class B — unguarded sites (bypass every framework guard):
- Hand-rolled 3xx
Responses:new Response(null, { status: 302, headers: { location: ... } })orheaders.set("location", ...)in loaders, middleware, or API handlers. With user input these allow not just open redirects but also unsafe schemes and header-injection attempts — flag at higher severity.
Step 2: Trace the input
For each site, identify whether the redirect target is:
- Static — string literal. Safe.
- Internal-derived — built from
params,route.path, or a closed allowlist. Safe if the allowlist is verifiable. - Request-derived — read from
url.searchParams.get(...),request.headers.get('referer'), request body fields, cookie values, or query parameters. Suspect.
Common suspect names: redirect, redirectTo, next, returnTo, continue,
url, dest, goto.
Step 3: Check the gate
For each request-derived target, look for one of:
| Gate | Safe? |
|---|---|
| Hardcoded allowlist of paths/origins | Yes |
new URL(target, base).origin === url.origin |
Yes — origin comparison |
target.startsWith('/') AND !target.startsWith('//') |
Bypassable (/\evil.example) |
new URL(target).hostname === expected |
Yes if expected is trusted |
| No check | Open redirect |
target.includes(domain) (substring) |
Bypassable (evil.com#yourdomain.com) |
| Regex without anchors | Likely bypassable |
Prefix checks are not sufficient. //evil.example/path is
protocol-relative, and URL parsing turns \ into / and strips tabs and
newlines, so /\evil.example and /\t/evil.example pass
startsWith('/') && !startsWith('//') yet land on another origin. Only an
origin comparison after new URL(target, url) is a real gate.
Remember: redirect()'s built-in validation covers scheme and CRLF only — it
happily redirects to any well-formed http(s) origin, so Class A sites still
need an origin/path gate for request-derived targets.
Step 4: Report
| File:Line | Class (A/B) | Source | Target expression | Gate | Severity | Verdict |
|---|
Severity is the primary scale; the verdict is a secondary domain label:
error/open— request-derived target, no check. A Class B site here is the worst case (no scheme/CRLF guard either) — say so explicitly.warn/risky— substring/regex check; suggest URL-parse rewrite.info/safe— static or properly gated. For Class B sites that are static today, still recommend migrating toredirect()so the scheme/CRLF guards apply if the target ever becomes dynamic.
For each open/risky finding, propose a fix snippet, e.g.:
function safeRedirectPath(value: unknown, base: URL, fallback: string): string {
if (typeof value !== "string" || !value.startsWith("/")) return fallback;
try {
const target = new URL(value, base);
return target.origin === base.origin
? `${target.pathname}${target.search}${target.hash}`
: fallback;
} catch {
return fallback;
}
}
return redirect(safeRedirectPath(url.searchParams.get("redirect"), url, "/dashboard"), {
request,
});
Step 5: Cross-check with the framework guards
Note in the report that pracht drops javascript:, data:, vbscript:,
blob:, and file: schemes both in the client router (since #122) and in
server-side redirect()/buildRedirectResponse() — so a guarded open
redirect cannot become script execution. But it can still phish (redirect to
a look-alike origin) and leak session/referrer headers, and Class B raw
Responses get none of this protection.
Rules
- Default to suspicion for any request-derived target.
- Recommend
URLparsing over string prefix checks for non-trivial gates. - Do not trust
refereras a gate; it is forgeable and often stripped. - After-login redirects are the most dangerous — user is authenticated.
- Recommend
redirect()over hand-rolled 3xx Responses so the scheme/CRLF guards apply. - Do not auto-fix; surface the gap and propose the patch.
$ARGUMENTS
Files (pracht)
-
SKILL.md 6.3 KB
--- name: audit-redirects version: 1.1.2 description: | Find open redirects in pracht loaders, middleware, and navigation. The framework rejects unsafe schemes and CRLF at both ends, but a hand-rolled 3xx Response bypasses every guard, and even a guarded redirect to an attacker-chosen origin can phish. Use for "audit redirects", "check for open redirects", "is my ?redirect= param safe", "review login redirect handling". allowed-tools: - Bash - Read - Grep - Glob --- # Pracht Audit Redirects The classic open-redirect bug: `/login?redirect=https://evil.example` → after login, the app redirects the user to attacker territory carrying a fresh session. What the framework guarantees: the client router drops non-`http(s):` schemes at the navigation boundary, and server-side `redirect()` / `buildRedirectResponse()` reject non-`http(s):` schemes and CR/LF injection against the `Location` header. What it cannot decide is whether the target *origin or path* is one you trust — and a raw `new Response(null, { status: 302, headers: { location: userInput } })` bypasses all of those guards entirely. Prerequisites: `pracht inspect` requires a vite config that registers the pracht plugin. ## Step 1: Inventory redirect sites Bound the search first — do not grep the whole repo blind: ```bash pracht inspect routes --json ``` MCP: when the pracht MCP server is registered (`pracht dev-mcp`), prefer its `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify` tools over shelling out. The resolved graph gives you each route's `file`, `loaderFile`, and `middleware` names (resolve names to files via the `defineApp({ middleware })` map in the manifest). Grep those files plus the API handler files from `pracht inspect api --json`, and client components, for two distinct classes: **Class A — guarded sites** (scheme/CRLF-safe, still open-redirect capable): - Middleware/loaders/handlers returning `redirect(...)` or `buildRedirectResponse(...)`. - `useNavigate()(value)` and `<a href={value}>` where `value` is dynamic (client guard applies). - `prefetchRouteState(value)` calls with dynamic input. **Class B — unguarded sites** (bypass every framework guard): - Hand-rolled 3xx `Response`s: `new Response(null, { status: 302, headers: { location: ... } })` or `headers.set("location", ...)` in loaders, middleware, or API handlers. With user input these allow not just open redirects but also unsafe schemes and header-injection attempts — flag at higher severity. ## Step 2: Trace the input For each site, identify whether the redirect target is: - **Static** — string literal. Safe. - **Internal-derived** — built from `params`, `route.path`, or a closed allowlist. Safe if the allowlist is verifiable. - **Request-derived** — read from `url.searchParams.get(...)`, `request.headers.get('referer')`, request body fields, cookie values, or query parameters. **Suspect.** Common suspect names: `redirect`, `redirectTo`, `next`, `returnTo`, `continue`, `url`, `dest`, `goto`. ## Step 3: Check the gate For each request-derived target, look for one of: | Gate | Safe? | | -------------------------------------- | ----- | | Hardcoded allowlist of paths/origins | Yes | | `new URL(target, base).origin === url.origin` | Yes — origin comparison | | `target.startsWith('/')` AND `!target.startsWith('//')` | **Bypassable** (`/\evil.example`) | | `new URL(target).hostname === expected` | Yes if `expected` is trusted | | No check | **Open redirect** | | `target.includes(domain)` (substring) | **Bypassable** (`evil.com#yourdomain.com`) | | Regex without anchors | **Likely bypassable** | Prefix checks are **not** sufficient. `//evil.example/path` is protocol-relative, and URL parsing turns `\` into `/` and strips tabs and newlines, so `/\evil.example` and `/\t/evil.example` pass `startsWith('/') && !startsWith('//')` yet land on another origin. Only an origin comparison after `new URL(target, url)` is a real gate. Remember: `redirect()`'s built-in validation covers scheme and CRLF only — it happily redirects to any well-formed `http(s)` origin, so Class A sites still need an origin/path gate for request-derived targets. ## Step 4: Report | File:Line | Class (A/B) | Source | Target expression | Gate | Severity | Verdict | | --------- | ----------- | ------ | ----------------- | ---- | -------- | ------- | Severity is the primary scale; the verdict is a secondary domain label: - `error` / `open` — request-derived target, no check. A Class B site here is the worst case (no scheme/CRLF guard either) — say so explicitly. - `warn` / `risky` — substring/regex check; suggest URL-parse rewrite. - `info` / `safe` — static or properly gated. For Class B sites that are static today, still recommend migrating to `redirect()` so the scheme/CRLF guards apply if the target ever becomes dynamic. For each `open`/`risky` finding, propose a fix snippet, e.g.: ```ts function safeRedirectPath(value: unknown, base: URL, fallback: string): string { if (typeof value !== "string" || !value.startsWith("/")) return fallback; try { const target = new URL(value, base); return target.origin === base.origin ? `${target.pathname}${target.search}${target.hash}` : fallback; } catch { return fallback; } } return redirect(safeRedirectPath(url.searchParams.get("redirect"), url, "/dashboard"), { request, }); ``` ## Step 5: Cross-check with the framework guards Note in the report that pracht drops `javascript:`, `data:`, `vbscript:`, `blob:`, and `file:` schemes both in the client router (since #122) and in server-side `redirect()`/`buildRedirectResponse()` — so a guarded open redirect cannot become script execution. But it can still phish (redirect to a look-alike origin) and leak session/referrer headers, and Class B raw Responses get none of this protection. ## Rules 1. Default to suspicion for any request-derived target. 2. Recommend `URL` parsing over string prefix checks for non-trivial gates. 3. Do not trust `referer` as a gate; it is forgeable and often stripped. 4. After-login redirects are the most dangerous — user is authenticated. 5. Recommend `redirect()` over hand-rolled 3xx Responses so the scheme/CRLF guards apply. 6. Do not auto-fix; surface the gap and propose the patch. $ARGUMENTS
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.