# xss.cool A tiny HTTP response playground. Build any HTTP response from URL parameters, save it as a share for a stable URL, and capture incoming requests. The same backend serves multiple host domains in parallel — currently xss.cool and ctf.baby — so any share or capture is reachable on every host. Pick whichever domain fits your test (cross-origin payloads, etc). ## Quick start (no account, no save) - GET https://xss.cool/?status=418&html=

hi

- GET https://xss.cool/?status=302&redirect=https://example.com&dryrun=1 → JSON describing the response instead of sending it ## Quick start (save a share) curl -s -X POST https://xss.cool/api/shares -H 'Content-Type: application/json' -d '{"state":{"status":200,"delay":0,"payloadParam":"html","editorLanguage":"html","editorContent":"

hi

","headers":[]}}' → { id, name, replayUrl: "https://xss.cool/s/", editorUrl: "https://xss.cool/e/", curlExample, jsExample, owned: true, ... } Keep the owner token to update or delete later: either keep the Set-Cookie (xsscool_owner), or mint one with POST /api/token and send it as "Authorization: Bearer " on every call. The xsscli command does this for you: install with `curl -fsSL https://xss.cool/cli/install.sh | sh` (or `npm i -g https://xss.cool/cli/xsscool-cli.tgz`), then `xsscli token new`. ## Endpoints Crafted responses: - GET / — editor SPA (no params); with params, serves a crafted response. - GET /?... — same crafted-response semantics on any path. Shares (a saved EditorState; one id, two views): - POST /api/shares — save. Body: { state: EditorState, name?: string, ttlDays?: 1..365|null }. 201 → Share. - GET /api/shares/mine — list your shares (owner token). → { shares: Share[] } - GET /api/shares/:id — one share with its state. Public; `owned` says whether you may edit it. - PATCH /api/shares/:id — owner only. Body: { state?, name?, ttlDays?, submittedPayloadId? }. → Share. - DELETE /api/shares/:id — owner only. 204. - GET /s/:id — replay the saved response. Accepts ?dryrun=1. HEAD/OPTIONS supported. - GET /e/:id — open the editor pre-loaded with the share. Owner tokens: - GET /api/token — your current token (sets the cookie if you had none). → { token } - POST /api/token — mint a fresh token, no cookie. → { token } Captures (webhook sinks): - POST /api/captures — create. Body: { label?: string }. → { id, captureUrl, streamUrl, requestsUrl, expiresAt } - GET /api/captures — list yours (owner token). - DELETE /api/captures/:id — owner only. → 204; 403 not owner, 404 unknown. Drops the logged requests too. CLI: xsscli capture rm -f - ANY /c/:id (and subpaths) — the sink. Logs method, path, query, headers, body (1 MB cap), source IP. - GET /c/:id/stream — Server-Sent Events feed of incoming requests (id: , data: ). - GET /api/captures/:id/requests?since=&limit= — list captured requests. → { requests: [...] } Payload library: - GET /api/payloads — public templates. → { payloads: [{ id, title, description, tags, state }] } - POST /api/payloads — submit a template for review (5/hr/owner). Body: { title, description, tags?, alias?, state }. - GET /api/payloads/mine — your submissions, any status. Misc: - GET /api/config — { domains: string[] } — the public hosts this deployment serves. - GET /api/schema.json — machine-readable version of this file. - GET /cli — how to install xsscli; /cli/install.sh, /cli/xsscli.js (single-file bundle), /cli/xsscool-cli.tgz. Errors are JSON: { error: string, issues?: ZodIssue[], hint?: string }. 400 invalid body, 403 not the owner, 404 unknown id, 409 share cap, 429 rate limit. ## Crafted-response query parameters Status: - status, statuscode, code (int 200-599; default 200; first defined wins) Timing: - delay (ms before the response starts; clamped 0..300000) - chunks (1..64) + chunkdelay (ms, 0..60000): send the body in N pieces with a pause between them (chunked transfer-encoding). Both must be set for drip to happen. Redirect (sets Location header): - redirect, redir (first defined wins) Body (checked in this order; first match used): - html=... — raw, Content-Type: text/html - script=... — wraps in , Content-Type: text/html - js=... — raw, Content-Type: application/javascript - text=... — raw, Content-Type: text/plain - json=... — raw, Content-Type: application/json - body=... — raw, Content-Type: text/html (same as html=, just lower precedence) Custom headers (qs-style nested): - h[Header-Name]=value preferred form - header[Header-Name]=value alternate - headers[Header-Name]=value alternate - Repeat the same key for multiple values (joined with ", "). Setting h[Content-Type] overrides the body param's default. - h[Content-Length]=N is passed through as-is, so a lying Content-Length is one param away. Self-verification: - dryrun=1 — instead of sending the response, return JSON { status, headers, body, contentType, delay, chunks, chunkDelay, method }. Works on crafted URLs and on /s/. Examples: - /?status=418&html=

hi

- /?delay=3000&text=slow - /?chunks=4&chunkdelay=500&text=dripping - /?redirect=https://example.com&status=302 - /?h[X-Frame-Options]=DENY&h[Content-Security-Policy]=default-src+'self'&html=

hi

- /?h[Set-Cookie]=a%3D1&h[Set-Cookie]=b%3D2 ## EditorState (the `state` object of a share) { "status": 200, // int 200..599 "delay": 0, // int 0..300000 ms "payloadParam": "html", // html | script | js | json | text | body — which body param the content maps to "editorLanguage": "html", // html | javascript | json | plaintext — editor syntax only "editorContent": "

hi

", "headers": [{ "id": "h1", "name": "X-Demo", "value": "1" }], // id: any unique string (React key, not a server id) "chunks": 1, // optional, 1..64 "chunkDelay": 0 // optional, ms 0..60000 } Redirects and CSP go in headers[] (e.g. { name: "Location", value: "..." } with status 302). /s/ re-derives the crafted-response query from this state at request time, so a share and a crafted URL are two views of the same thing. ## Share (API response shape) { id, name, preview, status, createdAt, updatedAt, expiresAt, submittedPayloadId, owned, replayUrl, editorUrl, urls?, curlExample, jsExample, state? } replayUrl/editorUrl are absolute on the host you called. `urls` maps every served domain to { replay, editor } — e.g. urls["xss.cool"].replay and urls["ctf.baby"].replay are the same share on two origins, which is all a cross-origin PoC needs. ## Cross-origin testing recipe 1. Save the attacker fixture (e.g. an iframe / fetch / postMessage page) once. Reference it on host A — e.g. https://xss.cool/s/. 2. Reference the victim URL on host B — e.g. https://ctf.baby/s/ or https://ctf.baby/?html=... 3. The two URLs are now genuinely cross-origin in the browser even though they hit the same backend. CORS, COOP/COEP, postMessage, SameSite cookies, and CSP frame-ancestors all behave as they would across distinct attacker/victim domains. Need a request to land somewhere? Create a capture and use its captureUrl as the exfil target; tail /c//stream or poll requestsUrl. ## Notes - Shares never expire unless created or patched with ttlDays. Captures expire after 7 days. - Soft limits: 60 share saves/min/IP, 500 shares per owner (409 when reached). - The editor's Domain switcher only changes which host the UI prints; every share and capture is reachable on every host.