Skip to content

Instantly share code, notes, and snippets.

@ei-grad
Created December 19, 2025 08:09
Show Gist options
  • Select an option

  • Save ei-grad/e88ba20d89b1f8b71a5ecd1b3f752dc1 to your computer and use it in GitHub Desktop.

Select an option

Save ei-grad/e88ba20d89b1f8b71a5ecd1b3f752dc1 to your computer and use it in GitHub Desktop.
CF Workers http-debug (secure?) handler
/**
* 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