Claude skill HTTP response splitting
HTTP response splitting and header injection attacks exploit a fundamental property of the HTTP/1.1 wire format: headers are separated from the body and from each other by carriage-return-linefeed (\r\n) sequences, and the header block ends with a blank line (\r\n\r\n). When a skill includes user-controlled data in any HTTP response header — a redirect URL, a filename in Content-Disposition, a cache key, an ETag value — and that data can contain \r\n characters, an attacker can inject additional headers or fabricate an entirely new HTTP response into the same connection. SA-SKL-SEC-005 through SEC-008 describe four patterns where user-controlled values reach HTTP response headers in ways that enable response splitting, XSS via MIME sniffing, reverse-proxy cache poisoning, and session state disclosure through log aggregation.
Modern Node.js and Express versions reject header values containing literal \r\n bytes with a TypeError. This is a partial mitigation: it catches the most obvious attack vector but does not protect against URL-encoded variants (%0d%0a) that are decoded before reaching the header-setting code, nor against attacks that use headers for cache poisoning or information disclosure without a literal CRLF injection. The four patterns in this family represent the attack surface that remains even when basic CRLF rejection is in place.
SA-SKL-SEC-005: CRLF injection in Location or Set-Cookie header via user-controlled redirect target
When a skill redirects to a URL that is partially or fully controlled by the user — an OAuth returnTo parameter, a next redirect value, a callback URL stored in state — and that URL is passed directly to res.redirect() or res.setHeader('Location', url), a CRLF injection attack is possible if URL-encoded newlines are decoded before the header is constructed. The attacker encodes \r\n as %0d%0a in the redirect target. If the skill decodes the URL before building the Location header, the decoded CRLF terminates the Location header and begins a new header line. The attacker injects Set-Cookie: session=attacker_session_id as the next line, forcing the victim’s browser to replace its session cookie with the attacker’s value. This achieves session fixation: the attacker knows the session ID because they set it; after the victim logs in on the fixed session, the attacker’s session acquires the victim’s identity.
// VULNERABLE: redirect to user-controlled URL without CRLF sanitisation
import express from 'express';
const app = express();
app.get('/auth/return', (req, res) => {
let returnTo = req.query.returnTo as string;
// Developer manually decodes URL-encoded value, then redirects
// This exposes decoded CRLF characters before the header is set
returnTo = decodeURIComponent(returnTo);
// Attack payload: returnTo = %2Fdashboard%0d%0aSet-Cookie%3A%20session%3Devil123%3B%20Path%3D%2F
// After decodeURIComponent → "/dashboard\r\nSet-Cookie: session=evil123; Path=/"
// Express rejects literal \r\n — but older HTTP parsers or proxies may not
res.redirect(returnTo);
// Even without splitting, res.redirect() to attacker.com is an open redirect
});
// ALSO VULNERABLE: building Location header manually from user input
app.get('/download', (req, res) => {
const filename = req.query.file as string;
// If filename is decoded and contains \r\n, the injected line appears in the response
res.setHeader('Location', `/files/${filename}`);
res.status(302).end();
});
// SECURE: validate redirect targets against an allowlist before redirecting
const ALLOWED_REDIRECTS = new Set([
'/dashboard',
'/settings',
'/profile',
'/audit/results',
]);
app.get('/auth/return', (req, res) => {
const returnTo = req.query.returnTo as string;
// Validate against allowlist BEFORE any decoding or redirect
// Never decode and then validate — validate the raw value or a parsed URL object
let safeReturn = '/dashboard'; // default
try {
// Parse as URL to normalise: relative paths get resolved against skill origin
const url = new URL(returnTo, 'https://skillaudit.dev');
// Only accept paths within our own origin and in the allowlist
if (url.origin === 'https://skillaudit.dev' && ALLOWED_REDIRECTS.has(url.pathname)) {
safeReturn = url.pathname;
}
} catch {
// Invalid URL — use default
}
// safeReturn is always a safe allowlisted pathname; no user-controlled string in header
res.redirect(safeReturn);
});
// DETECTION (SkillAudit):
// res.redirect(req.query.*) | res.redirect(req.body.*) | res.redirect(args.*)
// OR res.setHeader('Location', userInput) without allowlist validation
const crlfInjection = searchAST(
'res.redirect(userInput) OR setHeader("Location", userInput) WITHOUT allowlistCheck'
);
if (crlfInjection) → CRITICAL // SA-SKL-SEC-005
Open redirect + CRLF injection = session fixation: the combination of redirecting to attacker-controlled destinations and injecting Set-Cookie via CRLF allows an attacker to plant their session ID in the victim’s browser. After the victim authenticates, the attacker’s pre-planted session becomes authenticated. Allowlist validation of redirect targets eliminates both the open redirect and the injection vector simultaneously. Never decode a URL before validating it against an allowlist.
res.redirect(userInput) or setHeader('Location', userInput) without allowlist validation; URL-decoded CRLF in user input injects additional HTTP headers (e.g. Set-Cookie: session=evil); enables session fixation and header injection. Detection: redirect to user-controlled value without origin + allowlist check → CRITICAL.SA-SKL-SEC-006: newline injection in Content-Disposition filename enables XSS in legacy browser modes
Skills that allow users to specify a filename when downloading a generated document, export, or audit report often construct the Content-Disposition header by interpolating the user-supplied filename directly. If the filename contains a newline character — either as a literal \n or decoded from %0a — the HTTP response parser in older browsers and some reverse proxies treats everything after the newline as a new header. An attacker crafts a filename like report.pdf\nContent-Type: text/html\n\n<script>stealCookies()</script>. The injected Content-Type: text/html overrides the declared type, and the injected script tag becomes the body of the second “response” from the proxy’s perspective. While modern browsers are hardened against this specific vector, it remains exploitable in Internet Explorer compatibility mode, in HTTP/1.1 parsing layers in some shared reverse proxies, and in HTTP response parsing in automated tools (monitoring agents, vulnerability scanners, log parsers) that have less robust parsers than browsers. The fix requires sanitising the filename to contain only safe characters before including it in the header.
// VULNERABLE: Content-Disposition filename from user-controlled args
import express from 'express';
const app = express();
app.get('/skill/export', (req, res) => {
const filename = req.query.filename as string;
// Attack: filename = "report.pdf\nContent-Type: text/html\n\n<script>xss()</script>"
// After URL decode: real CRLF characters injected into header value
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'application/pdf');
res.send(generateReport());
// Older HTTP parsers split on \n; inject Content-Type: text/html + script body
});
// ALSO VULNERABLE: RFC 5987 encoding done incorrectly
app.get('/skill/download', (req, res) => {
const name = req.query.name as string;
// RFC 5987 percent-encoding but attacker can include literal CRLF before encoding
res.setHeader('Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(name)}`
);
// If name was decoded earlier (e.g. from a database storing a decoded value)
// the literal \r\n bypasses percent-encoding — now in the header as raw bytes
});
// SECURE: sanitise filename to safe characters only before header construction
app.get('/skill/export', (req, res) => {
const rawFilename = req.query.filename as string;
// Strip everything except alphanumerics, hyphens, underscores, dots
// This removes CR, LF, quotes, semicolons, and all other header-breaking characters
const safeFilename = rawFilename
.replace(/[^a-zA-Z0-9\-_.]/g, '_') // allow only safe chars
.slice(0, 100); // cap length
// Double-check: no CR/LF characters remain
if (/[\r\n]/.test(safeFilename)) {
return res.status(400).send('Invalid filename');
}
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"`);
res.send(generateReport());
});
// DETECTION (SkillAudit):
// setHeader('Content-Disposition', `*${userInput}*`) without CRLF strip
const headerInjection = searchAST(
"setHeader('Content-Disposition', templateLiteral(userInput)) WITHOUT crlfStrip"
);
if (headerInjection) → HIGH // SA-SKL-SEC-006
The filename sanitisation pattern applies to all user-controlled header values: any header constructed with string interpolation from user input is potentially injectable. Content-Disposition is the most common because skills frequently expose file download endpoints where the filename comes from user input or from a database field that was originally user-supplied. Whitelist-sanitise to [a-zA-Z0-9\-_.] before interpolating into any header value, and cap length at 100 characters to prevent header value overflow attacks.
setHeader('Content-Disposition', attachment; filename="${userInput}") without CRLF sanitisation; newline in filename injects additional headers; Content-Type: text/html injection enables XSS in legacy browser parsing contexts. Detection: Content-Disposition header built with template literal from user input without character-class sanitisation → HIGH.SA-SKL-SEC-007: user-controlled value in any response header enables cache poisoning in shared reverse proxies
Cache poisoning via HTTP header injection targets a different victim than classic CRLF attacks: not the individual user whose request triggered the injection, but all subsequent users whose requests are served the cached poisoned response. In a deployment where the MCP skill sits behind a shared reverse proxy (Nginx, Varnish, Cloudflare, an API gateway), an attacker crafts a request that causes the skill to include a malicious value in a response header that the proxy caches and forwards to other users. The attack does not require CRLF injection: the attacker can abuse header fields that the proxy includes in its cache key or that downstream consumers interpret, such as injecting a X-Forwarded-For value into a response that the proxy echoes back, or causing the skill to set a cache-poisoning-friendly Vary header value. The highest-severity variant uses a skill that echoes the Host header or a custom forwarding header into its response, combined with a proxy configured to cache on URL path: the attacker sends a poisoned request, the proxy caches the poisoned response, and all subsequent users on that path receive the attacker’s injected content until the cache expires.
// VULNERABLE: skill echoes request headers into response headers
import express from 'express';
const app = express();
// Anti-pattern: reflecting request headers into response (for debugging)
app.use((req, res, next) => {
// This debug middleware reflects X-Request-ID from request into response
// Attacker sends: X-Request-ID: legit\r\nX-Injected: poisoned-value
const requestId = req.headers['x-request-id'] as string;
if (requestId) {
res.setHeader('X-Request-ID', requestId);
// If proxy caches this response keyed on URL, next user gets injected header
}
next();
});
// ALSO VULNERABLE: skill echoes Host header into Location on redirect
app.get('/redirect', (req, res) => {
const host = req.headers.host;
const path = req.query.to as string;
// Attacker controls host header via HTTP/1.0 proxy — poisons Location for all users
res.redirect(`https://${host}${path}`);
});
// ALSO VULNERABLE: Vary header constructed from user input
app.get('/skill/response', (req, res) => {
const acceptFormat = req.headers['x-accept-format'] as string;
// Attacker injects arbitrary Vary header values — disrupts proxy caching logic
res.setHeader('Vary', `Accept-Encoding, X-Accept-Format, ${acceptFormat}`);
res.send('...');
});
// SECURE: never reflect request headers into response headers
// Use generated values for response headers that identify the request
import crypto from 'crypto';
app.use((req, res, next) => {
// Generate a server-side request ID — never echo client-supplied values
const requestId = crypto.randomUUID();
res.setHeader('X-Request-ID', requestId);
// Client can observe the assigned ID in the response, not control it
next();
});
// For redirect targets: validate against allowlist (see SEC-005)
// For Vary headers: use a fixed, hardcoded set — never append user values
app.get('/skill/response', (req, res) => {
res.setHeader('Vary', 'Accept-Encoding, Accept'); // fixed set only
res.send('...');
});
// DETECTION (SkillAudit):
// res.setHeader(*, req.headers.*) | res.setHeader(*, req.query.*) | res.setHeader(*, args.*)
const cachePoisoning = searchAST(
'setHeader(headerName, userControlledValue) INCLUDING request-header-reflection'
);
if (cachePoisoning) → HIGH // SA-SKL-SEC-007
Cache poisoning scales the impact of a single attacker request to all users: a successful cache poison persists until the cache TTL expires or the cache is purged. In a CDN deployment, that can mean thousands of users receive malicious content from a single crafted request. The rule is simple: never echo client-supplied header values into response headers. Use server-generated values (UUIDs, timestamps, server-side hashes) for any response header that identifies the request. Treat all incoming headers as untrusted user input.
X-Request-ID, Host, X-Forwarded-For) into HTTP response headers; shared reverse proxy caches the poisoned response; all subsequent users on that cache key receive attacker-controlled header values. Detection: setHeader(key, req.headers[*]) or setHeader(key, req.query[*]) pattern in response middleware → HIGH.SA-SKL-SEC-008: ETag constructed from user identity leaks session state to log aggregation
The HTTP ETag response header is a cache validator: a string that uniquely identifies the version of a resource. For dynamic skill responses that vary by user — personalised dashboards, per-user audit results, user-specific settings pages — some skills construct the ETag by combining the user ID or session token with a content hash. This ETag value then appears in the ETag response header, the If-None-Match request header on subsequent requests, and in the access logs of every intermediary that processed either request: Nginx, Varnish, Cloudflare, the skill’s own web server log. Unauthenticated users can read the ETag of any publicly cached response and extract the embedded user identifier. A log consumer with access to the web server access log — a monitoring agent, a log shipper running as a lower-privilege service, a log aggregation system with broader access than the application — can extract user IDs from ETag values in the log stream. ETags should be constructed from content hashes or version numbers, never from user-identifying data.
// VULNERABLE: ETag embeds user ID directly
import express from 'express';
import crypto from 'crypto';
const app = express();
app.get('/skill/dashboard', (req, res) => {
const userId = req.session.userId;
const dashboardData = fetchDashboard(userId);
const contentHash = crypto.createHash('md5').update(JSON.stringify(dashboardData)).digest('hex');
// ETag encodes userId — appears in access logs and If-None-Match headers
// Access log entry: GET /skill/dashboard HTTP/1.1 200 - ETag: "user_7829-a3f4b2c1"
// Anyone reading the log (log shipper, monitoring agent) extracts userId=7829
res.setHeader('ETag', `"user_${userId}-${contentHash}"`);
res.json(dashboardData);
});
// ALSO VULNERABLE: ETag contains session token fragment
app.get('/skill/settings', (req, res) => {
const sessionToken = req.session.token;
// ETag leaks first 8 chars of session token — enough for partial brute-force
res.setHeader('ETag', `"${sessionToken.slice(0, 8)}-v2"`);
res.json(getUserSettings(req.session.userId));
});
// ALSO VULNERABLE: ETag in Vary-keyed cache exposes user data to unauthenticated cache reads
app.get('/skill/export/:userId', (req, res) => {
// Public URL path includes userId; ETag includes session info
// Unauthenticated request to same URL + matching If-None-Match returns cached ETag
res.setHeader('ETag', `"${req.params.userId}-${Date.now()}"`);
res.json(getExport(req.params.userId));
});
// SECURE: ETag derived from content hash only — no user-identifying information
app.get('/skill/dashboard', (req, res) => {
const userId = req.session.userId;
const dashboardData = fetchDashboard(userId);
// ETag is a hash of the content only — reveals nothing about who the user is
const etag = crypto
.createHash('sha256')
.update(JSON.stringify(dashboardData))
.digest('base64url')
.slice(0, 16);
res.setHeader('ETag', `"${etag}"`);
res.json(dashboardData);
// Access log: GET /skill/dashboard 200 - ETag: "xKqP2mZ8nL4vW1aF"
// No user identity extractable from ETag value
});
// DETECTION (SkillAudit):
// setHeader('ETag', template containing req.session.userId | req.session.token | req.params.*)
const etagLeak = searchAST(
"setHeader('ETag', templateLiteral(sessionData | userId | sessionToken))"
);
if (etagLeak) → MEDIUM // SA-SKL-SEC-008
ETags are logged by every intermediary that processes the request: web server access logs, CDN edge logs, reverse proxy logs, and APM tracing systems all record ETag header values. Log access controls are rarely as strict as application-level session controls — a monitoring service that needs log access to detect errors often has read access to all log fields. Constructing ETags from content hashes rather than user identifiers costs nothing and eliminates all user-identity disclosure risk from the log layer. Apply the same principle to any custom header that might contain user-correlating data.
ETag response header constructed with user ID, session token fragment, or other user-identifying data; ETag values appear in web server access logs, CDN edge logs, and If-None-Match request headers; lower-privilege log consumers extract user identity from the log stream. Detection: setHeader('ETag', templateLiteral(req.session.* | req.params.userId)) → MEDIUM.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| SEC-005: CRLF injection in Location header | CRITICAL | Validate redirect targets against an origin-exact + allowlisted-path allowlist before redirecting; never decode user input before allowlist check; default to a safe hardcoded path if validation fails |
| SEC-006: Header injection via Content-Disposition filename | HIGH | Sanitise filenames to [a-zA-Z0-9\-_.] with explicit length cap before header interpolation; reject or replace any character outside this set; test with /[\r\n]/.test(filename) as a final guard |
| SEC-007: Cache poisoning via reflected request headers | HIGH | Never echo client-supplied header values into response headers; use crypto.randomUUID() for request IDs; use a fixed hardcoded set for Vary headers; treat all incoming headers as untrusted |
| SEC-008: ETag session state leakage | MEDIUM | Construct ETags from sha256(content).slice(0,16) only; never include user ID, session token, or any user-identifying data; apply the same rule to all custom response headers |
SkillAudit detects all four SA-SKL-SEC-005 through SEC-008 HTTP header injection patterns through static analysis of response header construction code. See SA-SKL-SEC-001 through SEC-004 (missing security headers: X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy) for the complementary missing-header findings. For input-layer injection patterns, see SA-SKL-STR input sanitization bypass. See the complete SA-SKL attack surface reference for all vulnerability families.
Scan your Claude skill for HTTP response splitting vulnerabilities
SkillAudit detects CRLF injection, Content-Disposition header injection, cache poisoning via reflected headers, and ETag session leakage in the full SA-SKL-SEC family in 60 seconds.
Get your free audit