Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Download jovidecroock-pracht-skills_audit-redirects-8bcdd52.zip · 2 KB

Install

skills CLI npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-redirects
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
Git 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(...) 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 Responses: 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.:

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

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.

No comments yet.

Reviews (0)

No reviews yet.