Created
December 19, 2025 08:09
-
-
Save ei-grad/e88ba20d89b1f8b71a5ecd1b3f752dc1 to your computer and use it in GitHub Desktop.
CF Workers http-debug (secure?) handler
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Cloudflare Worker HTTP debug handler (SECURED) | |
| * | |
| * Endpoint: | |
| * /debug | |
| * | |
| * Query params: | |
| * code: integer 200..599 (inclusive). Default 200. | |
| * body: response body content. | |
| * content_type: "json" or "application/json" for JSON output. Defaults to "text/plain". | |
| * header_<Name>: sets response header <Name>: <value>. | |
| * | |
| * Security Enforcements: | |
| * 1. Content-Type is RESTRICTED to "text/plain" or "application/json". | |
| * 2. Content-Security-Policy blocks all execution. | |
| * 3. Cache-Control is FORCED to "no-store". | |
| * 4. Set-Cookie is blocked. | |
| * 5. Redirects are restricted to strict allowlist (same-origin or specified hardcoded domains). | |
| * | |
| * Defaults: | |
| * - For no-body statuses (204, 205, 304) body is forced to null. | |
| * - If content_type=json is used without a body, a default JSON object is returned. | |
| */ | |
| const REASONS = { | |
| 200: "OK", | |
| 201: "Created", | |
| 202: "Accepted", | |
| 204: "No Content", | |
| 205: "Reset Content", | |
| 206: "Partial Content", | |
| 301: "Moved Permanently", | |
| 302: "Found", | |
| 303: "See Other", | |
| 307: "Temporary Redirect", | |
| 308: "Permanent Redirect", | |
| 400: "Bad Request", | |
| 401: "Unauthorized", | |
| 403: "Forbidden", | |
| 404: "Not Found", | |
| 409: "Conflict", | |
| 410: "Gone", | |
| 413: "Payload Too Large", | |
| 414: "URI Too Long", | |
| 415: "Unsupported Media Type", | |
| 418: "I'm a teapot", | |
| 422: "Unprocessable Content", | |
| 429: "Too Many Requests", | |
| 431: "Request Header Fields Too Large", | |
| 451: "Unavailable For Legal Reasons", | |
| 500: "Internal Server Error", | |
| 501: "Not Implemented", | |
| 502: "Bad Gateway", | |
| 503: "Service Unavailable", | |
| 504: "Gateway Timeout", | |
| }; | |
| const NO_BODY_STATUSES = new Set([204, 205, 304]); | |
| // Headers that the user is NOT allowed to set/override via query params | |
| // to ensure the security of the debug endpoint. | |
| const FORBIDDEN_HEADERS = new Set([ | |
| "content-type", | |
| "content-length", | |
| "content-encoding", | |
| "transfer-encoding", | |
| "cache-control", | |
| "set-cookie", | |
| "content-security-policy", | |
| "x-content-type-options", | |
| "strict-transport-security", | |
| "connection", | |
| "upgrade", | |
| ]); | |
| function reasonFor(status) { | |
| return REASONS[status] ?? `HTTP ${status}`; | |
| } | |
| function isAllowedRedirectLocation(value, requestUrl) { | |
| if (!value) return false; | |
| // 1. Allow same-origin relative paths | |
| if (value.startsWith("/")) return true; | |
| let u; | |
| try { | |
| u = new URL(value, requestUrl); // Handle relative URLs correctly | |
| } catch { | |
| return false; | |
| } | |
| // 2. Must be HTTPS | |
| if (u.protocol !== "https:") return false; | |
| const host = u.hostname.toLowerCase(); | |
| // 3. Exact match OR subdomain match | |
| if (host === "example.com" || host.endsWith(".example.com")) return true; | |
| return false; | |
| } | |
| function parseStatus(qp) { | |
| const raw = qp.get("code"); | |
| if (raw == null) return 200; | |
| const n = Number.parseInt(raw, 10); | |
| // Workers: only 200..599 supported for Response status. | |
| if (!Number.isFinite(n) || n < 200 || n > 599) return 200; | |
| return n; | |
| } | |
| export default { | |
| async fetch(request) { | |
| const url = new URL(request.url); | |
| if (url.pathname !== "/debug") { | |
| return new Response("not found", { status: 404 }); | |
| } | |
| const qp = url.searchParams; | |
| const status = parseStatus(qp); | |
| const headers = new Headers(); | |
| // --- 1. Process User Headers (with filtering) --- | |
| for (const [k, v] of qp.entries()) { | |
| if (!k.startsWith("header_")) continue; | |
| const name = k.slice("header_".length).trim(); | |
| const lowerName = name.toLowerCase(); | |
| if (!name) continue; | |
| // Ignore dangerous headers | |
| if (FORBIDDEN_HEADERS.has(lowerName)) continue; | |
| // Validate Location for redirects | |
| if (lowerName === "location") { | |
| if (!isAllowedRedirectLocation(v, url)) { | |
| return new Response("Blocked: unsafe redirect destination\n", { | |
| status: 400, | |
| headers: { | |
| "Content-Type": "text/plain", | |
| "X-Content-Type-Options": "nosniff", | |
| }, | |
| }); | |
| } | |
| } | |
| headers.set(name, v); | |
| } | |
| // --- 2. Determine Content-Type (Strict Whitelist) --- | |
| const requestedType = qp.get("content_type"); | |
| const isJson = | |
| requestedType === "json" || requestedType === "application/json"; | |
| // Only allow JSON or Plain Text. Never HTML. | |
| headers.set( | |
| "Content-Type", | |
| isJson ? "application/json" : "text/plain; charset=utf-8" | |
| ); | |
| // --- 3. Enforce Security Headers (Overrides any user input) --- | |
| // Prevent Sniffing: Browser must respect Content-Type. | |
| headers.set("X-Content-Type-Options", "nosniff"); | |
| // Prevent Execution: Even if Content-Type fails, CSP stops JS execution. | |
| // 'frame-ancestors none' prevents embedding in iframes. | |
| headers.set( | |
| "Content-Security-Policy", | |
| "default-src 'none'; frame-ancestors 'none'" | |
| ); | |
| // Prevent Caching: Ensure debug payloads aren't stored by intermediate CDNs or browsers. | |
| headers.set("Cache-Control", "no-store"); | |
| // --- 4. Body Handling --- | |
| let body; | |
| if (NO_BODY_STATUSES.has(status)) { | |
| body = null; | |
| } else if (qp.has("body")) { | |
| // User provided explicit body | |
| body = qp.get("body"); | |
| } else { | |
| // Auto-generate default body | |
| const reason = reasonFor(status); | |
| if (isJson) { | |
| body = JSON.stringify({ code: status, reason: reason }); | |
| } else { | |
| body = reason; | |
| } | |
| } | |
| return new Response(body, { status, headers }); | |
| }, | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment