Improper Neutralization of HTTP Headers for Scripting Syntax

Description

Improper Neutralization of HTTP Headers for Scripting Syntax occurs when user-controlled data is placed into HTTP response headers without proper sanitization. Attackers can inject newline characters (CRLF - \r\n) to add arbitrary headers or modify existing ones, potentially leading to HTTP Response Splitting, cache poisoning, cross-site scripting via headers, or session fixation attacks.

Risk

HTTP Response Splitting allows injection of entirely new HTTP responses. Cache poisoning affects all users receiving cached malicious content. XSS through headers like Content-Type or Content-Disposition. Session fixation by injecting Set-Cookie headers. Header injection can redirect users to malicious sites. Security headers (CSP, X-XSS-Protection) can be overwritten or removed.

Solution

Validate and sanitize all user input before including in headers. Reject or encode CR (\r, %0d) and LF (\n, %0a) characters. Use framework-provided header setting functions that auto-escape. Implement strict input validation with whitelisting. Verify header values match expected formats. Use security frameworks that prevent header injection.

Common Consequences

ImpactDetails
IntegrityScope: Response Manipulation

HTTP responses modified or split.
ConfidentialityScope: Data Exposure

Cache poisoning exposes data to wrong users.
Access ControlScope: Session Attacks

Session fixation or hijacking.

Example Code + Solution Code

Vulnerable Code

// VULNERABLE: User input directly in header
@WebServlet("/redirect")
public class VulnerableRedirectServlet extends HttpServlet {

    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String target = req.getParameter("url");

        // Vulnerable: CRLF injection possible
        resp.setHeader("Location", target);
        resp.setStatus(302);

        // Attack: url=http://good.com%0d%0aSet-Cookie:%20admin=true
        // Results in:
        // Location: http://good.com
        // Set-Cookie: admin=true
    }
}

// VULNERABLE: Filename in Content-Disposition
@WebServlet("/download")
public class VulnerableDownloadServlet extends HttpServlet {

    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String filename = req.getParameter("file");

        // Vulnerable: CRLF injection in header
        resp.setHeader("Content-Disposition",
                       "attachment; filename=" + filename);

        // Attack: file=test.txt%0d%0aContent-Type:%20text/html%0d%0a%0d%0a<script>alert(1)</script>
        // Causes XSS!
    }
}

// VULNERABLE: Custom header from user input
@WebServlet("/api")
public class VulnerableApiServlet extends HttpServlet {

    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        String requestId = req.getParameter("requestId");

        // Vulnerable
        resp.setHeader("X-Request-ID", requestId);

        // Attack: requestId=abc%0d%0aSet-Cookie:%20session=hijacked
    }
}
# VULNERABLE: Flask header injection
from flask import Flask, request, make_response, redirect

app = Flask(__name__)

@app.route('/redirect')
def redirect_vulnerable():
    url = request.args.get('url')
    # Vulnerable: CRLF injection
    response = redirect(url)
    return response
    # Attack: url=http://good.com%0d%0aX-Injected:%20value

@app.route('/download')
def download_vulnerable():
    filename = request.args.get('filename')
    response = make_response(get_file_content())
    # Vulnerable header
    response.headers['Content-Disposition'] = f'attachment; filename={filename}'
    return response
    # Attack: filename=test%0d%0aContent-Type:%20text/html

@app.route('/api/data')
def api_vulnerable():
    callback = request.args.get('callback')
    response = make_response(get_data())
    # Vulnerable: user input in header
    response.headers['X-Callback'] = callback
    return response
    # Attack: callback=test%0d%0aSet-Cookie:%20admin=true

# VULNERABLE: Response splitting for XSS
@app.route('/search')
def search_vulnerable():
    query = request.args.get('q')
    response = make_response(f"Search results for: {query}")
    # Vulnerable header
    response.headers['X-Search-Query'] = query
    return response
    # Attack: q=test%0d%0a%0d%0a<html><script>alert(document.cookie)</script>
// VULNERABLE: Node.js/Express header injection
const express = require('express');
const app = express();

app.get('/redirect', (req, res) => {
    const url = req.query.url;
    // Vulnerable: CRLF in redirect
    res.redirect(url);
    // Attack: url=http://good.com%0d%0aSet-Cookie:%20evil=true
});

app.get('/download', (req, res) => {
    const filename = req.query.filename;
    // Vulnerable: user input in header
    res.setHeader('Content-Disposition', `attachment; filename=${filename}`);
    res.send(fileContent);
    // Attack: filename=test.txt%0d%0aContent-Type:%20text/html
});

app.get('/api/callback', (req, res) => {
    const callback = req.query.callback;
    // Vulnerable
    res.setHeader('X-Callback', callback);
    res.json({ data: 'response' });
    // Attack: callback=test%0d%0aAccess-Control-Allow-Origin:%20*
});

// VULNERABLE: Language preference header
app.get('/content', (req, res) => {
    const lang = req.query.lang;
    // Vulnerable
    res.setHeader('Content-Language', lang);
    res.send(getContent(lang));
    // Attack: lang=en%0d%0aSet-Cookie:%20session=stolen
});
<?php
// VULNERABLE: Direct header setting
$redirect = $_GET['redirect'];
// Vulnerable: CRLF injection
header("Location: $redirect");
// Attack: redirect=http://good.com%0d%0aSet-Cookie:%20admin=true

// VULNERABLE: Cookie from user input
$theme = $_GET['theme'];
// Vulnerable
header("Set-Cookie: theme=$theme");
// Attack: theme=dark%0d%0aSet-Cookie:%20session=hijacked

// VULNERABLE: Content-Disposition
$filename = $_GET['file'];
// Vulnerable
header("Content-Disposition: attachment; filename=$filename");
// Attack: file=test.pdf%0d%0aContent-Type:%20text/html

// VULNERABLE: Custom headers
$requestId = $_GET['id'];
// Vulnerable
header("X-Request-ID: $requestId");
// Attack: id=123%0d%0a%0d%0a<script>alert(1)</script>
// HTTP Response Splitting - entire new response injected
?>

Fixed Code

// SAFE: Validated header values
@WebServlet("/redirect")
public class SafeRedirectServlet extends HttpServlet {

    private static final Set<String> ALLOWED_HOSTS = Set.of(
        "example.com", "subdomain.example.com"
    );

    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String target = req.getParameter("url");

        // Validate and sanitize
        if (!isValidRedirectUrl(target)) {
            resp.sendError(400, "Invalid redirect URL");
            return;
        }

        // Use sendRedirect which handles encoding
        resp.sendRedirect(target);
    }

    private boolean isValidRedirectUrl(String url) {
        if (url == null || url.isEmpty()) {
            return false;
        }

        // Reject CRLF characters
        if (url.contains("\r") || url.contains("\n") ||
            url.contains("%0d") || url.contains("%0a") ||
            url.contains("%0D") || url.contains("%0A")) {
            return false;
        }

        try {
            URI uri = new URI(url);
            // Whitelist allowed hosts
            return ALLOWED_HOSTS.contains(uri.getHost());
        } catch (URISyntaxException e) {
            return false;
        }
    }
}

// SAFE: Sanitized Content-Disposition
@WebServlet("/download")
public class SafeDownloadServlet extends HttpServlet {

    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String filename = req.getParameter("file");

        // Sanitize filename
        String safeFilename = sanitizeFilename(filename);
        if (safeFilename == null) {
            resp.sendError(400, "Invalid filename");
            return;
        }

        // Use proper encoding for Content-Disposition
        String encodedFilename = URLEncoder.encode(safeFilename, "UTF-8")
                                          .replaceAll("\\+", "%20");

        resp.setHeader("Content-Disposition",
                       "attachment; filename*=UTF-8''" + encodedFilename);

        // Send file...
    }

    private String sanitizeFilename(String filename) {
        if (filename == null || filename.isEmpty()) {
            return null;
        }

        // Remove path components
        filename = new File(filename).getName();

        // Remove control characters and CRLF
        filename = filename.replaceAll("[\\x00-\\x1f\\x7f]", "");

        // Validate remaining characters
        if (!filename.matches("^[a-zA-Z0-9._-]+$")) {
            return null;
        }

        return filename;
    }
}

// SAFE: Using response wrapper that blocks header injection
public class HeaderSanitizingFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) res;
        HttpServletResponseWrapper wrapper = new SafeHeaderResponseWrapper(response);
        chain.doFilter(req, wrapper);
    }
}

class SafeHeaderResponseWrapper extends HttpServletResponseWrapper {

    public SafeHeaderResponseWrapper(HttpServletResponse response) {
        super(response);
    }

    @Override
    public void setHeader(String name, String value) {
        super.setHeader(sanitize(name), sanitize(value));
    }

    @Override
    public void addHeader(String name, String value) {
        super.addHeader(sanitize(name), sanitize(value));
    }

    private String sanitize(String value) {
        if (value == null) return null;
        // Remove CRLF and other control characters
        return value.replaceAll("[\\r\\n]", "");
    }
}
# SAFE: Flask with proper header handling
from flask import Flask, request, make_response, redirect, abort
from urllib.parse import urlparse, quote
import re

app = Flask(__name__)

ALLOWED_HOSTS = {'example.com', 'subdomain.example.com'}

def sanitize_header_value(value):
    """Remove CRLF and control characters from header value."""
    if value is None:
        return None
    # Remove CR, LF, and other control characters
    return re.sub(r'[\x00-\x1f\x7f]', '', value)

def validate_redirect_url(url):
    """Validate redirect URL against whitelist."""
    if not url:
        return None

    # Check for CRLF characters
    if any(c in url.lower() for c in ['\r', '\n', '%0d', '%0a']):
        return None

    try:
        parsed = urlparse(url)
        if parsed.netloc not in ALLOWED_HOSTS:
            return None
        return url
    except Exception:
        return None

@app.route('/redirect')
def redirect_safe():
    url = request.args.get('url')
    safe_url = validate_redirect_url(url)

    if not safe_url:
        abort(400, 'Invalid redirect URL')

    return redirect(safe_url)

@app.route('/download')
def download_safe():
    filename = request.args.get('filename', '')

    # Sanitize filename
    safe_filename = sanitize_filename(filename)
    if not safe_filename:
        abort(400, 'Invalid filename')

    response = make_response(get_file_content())

    # Use RFC 5987 encoding for non-ASCII filenames
    encoded_filename = quote(safe_filename)
    response.headers['Content-Disposition'] = \
        f"attachment; filename*=UTF-8''{encoded_filename}"

    return response

def sanitize_filename(filename):
    if not filename:
        return None

    # Remove path components
    import os
    filename = os.path.basename(filename)

    # Remove control characters
    filename = re.sub(r'[\x00-\x1f\x7f]', '', filename)

    # Validate
    if not re.match(r'^[a-zA-Z0-9._-]+$', filename):
        return None

    return filename

@app.route('/api/data')
def api_safe():
    callback = request.args.get('callback', '')

    # Validate callback (alphanumeric only)
    if not re.match(r'^[a-zA-Z0-9_]+$', callback):
        callback = ''

    response = make_response(get_data())

    # Sanitize before setting header
    safe_callback = sanitize_header_value(callback)
    if safe_callback:
        response.headers['X-Callback'] = safe_callback

    return response
// SAFE: Express with header sanitization
const express = require('express');
const app = express();

const ALLOWED_HOSTS = new Set(['example.com', 'subdomain.example.com']);

function sanitizeHeaderValue(value) {
    if (!value) return '';
    // Remove CR, LF, and control characters
    return value.replace(/[\x00-\x1f\x7f]/g, '');
}

function validateRedirectUrl(url) {
    if (!url) return null;

    // Check for CRLF
    if (/[\r\n]|%0[dD]|%0[aA]/i.test(url)) {
        return null;
    }

    try {
        const parsed = new URL(url);
        if (!ALLOWED_HOSTS.has(parsed.hostname)) {
            return null;
        }
        return url;
    } catch {
        return null;
    }
}

app.get('/redirect', (req, res) => {
    const url = req.query.url;
    const safeUrl = validateRedirectUrl(url);

    if (!safeUrl) {
        return res.status(400).send('Invalid redirect URL');
    }

    res.redirect(safeUrl);
});

app.get('/download', (req, res) => {
    const filename = req.query.filename || '';

    // Sanitize filename
    const safeFilename = sanitizeFilename(filename);
    if (!safeFilename) {
        return res.status(400).send('Invalid filename');
    }

    // Use proper encoding
    const encodedFilename = encodeURIComponent(safeFilename);
    res.setHeader('Content-Disposition',
        `attachment; filename*=UTF-8''${encodedFilename}`);

    res.send(fileContent);
});

function sanitizeFilename(filename) {
    if (!filename) return null;

    // Remove path
    const path = require('path');
    filename = path.basename(filename);

    // Remove control characters
    filename = filename.replace(/[\x00-\x1f\x7f]/g, '');

    // Validate
    if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
        return null;
    }

    return filename;
}

// Middleware to sanitize all headers
app.use((req, res, next) => {
    const originalSetHeader = res.setHeader.bind(res);

    res.setHeader = (name, value) => {
        if (typeof value === 'string') {
            value = sanitizeHeaderValue(value);
        }
        return originalSetHeader(name, value);
    };

    next();
});

Exploited in the Wild

HTTP Response Splitting

Cache poisoning through header injection.

Session Fixation

Injecting Set-Cookie headers for session attacks.

XSS via Headers

Cross-site scripting through manipulated headers.


Tools to test/exploit

  • Burp Suite — CRLF injection testing.

  • OWASP ZAP HTTP header fuzzing.

  • Manual testing with %0d%0a sequences.


CVE Examples

  • CVE-2019-14234: Django header injection.

  • Numerous CRLF injection CVEs in web frameworks.


References

  1. MITRE. "CWE-644: Improper Neutralization of HTTP Headers for Scripting Syntax." https://cwe.mitre.org/data/definitions/644.html

  2. OWASP. "HTTP Response Splitting."