# 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.