Skip to content

fix(ext/http): treat empty Host header as missing in request URL#33234

Open
jeanibarz wants to merge 1 commit intodenoland:mainfrom
jeanibarz:fix/29872-serve-request-url-validity
Open

fix(ext/http): treat empty Host header as missing in request URL#33234
jeanibarz wants to merge 1 commit intodenoland:mainfrom
jeanibarz:fix/29872-serve-request-url-validity

Conversation

@jeanibarz
Copy link
Copy Markdown

Fixes #29872.

Problem

When a client sends an HTTP/1.1 request with an empty Host: header, Deno.serve() produced a request.url that was either unparseable or — worse — silently promoted the URL path to the hostname:

  • GET / HTTP/1.1\r\nHost:\r\n\r\nrequest.url = "http:///"new URL(request.url) throws TypeError: Invalid URL
  • GET /path HTTP/1.1\r\nHost:\r\n\r\nrequest.url = "http:///path", which new URL() parses as http://path/. The path component becomes the URL hostname, which is a correctness and security concern for handlers that route on new URL(request.url).hostname.

The original report (#29872) hit the first case on Deno 2.1.6 via a production server receiving a bot probe. The naked IP form (90.151.171.106:443) shown in the issue stack trace is now rejected by hyper as 400 before reaching the handler, but the underlying invariant — "request.url is always a valid URL that identifies the actual server" — was still broken via the empty-Host path.

Root cause

ext/http/request_properties.rs::req_host returned Some("") for an empty Host: header. The op in http_next.rs forwarded that empty string verbatim to JS, and ext/http/00_serve.ts used ?? to coalesce the authority with the listener's fallback host — but ?? does not coalesce empty strings, so "" reached the URL builder and produced http:// + "" + /path = http:///path.

Fix

Treat an empty Host: header value as if no Host header were sent, so req_host returns None and the listener's fallback authority (already computed at listen time) is used. Hyper's HTTP/1 parser already trims OWS from header values, so whitespace-only Host: values arrive here as empty bytes and are covered by the same check.

The change is scoped to one function. The ?? coalesce in 00_serve.ts is unchanged and still load-bearing for the case where no Host header is sent at all.

Verification

Reproduced the bug locally, applied the fix, rebuilt deno, ran the new test against both versions:

Before the fix (httpServerUrlWithEmptyHostHeader added to the existing createUrlTest table):

httpServerUrlWithEmptyHostHeader ... FAILED
  [Diff] Actual / Expected
  -   http://path/
  +   http://localhost:42449/path

After the fix:

running 14 tests from ./tests/unit/serve_test.ts
httpServerUrlWithPath ... ok (6ms)
... (12 other URL-shape cases) ...
httpServerUrlWithEmptyHostHeader ... ok (5ms)

ok | 14 passed | 0 failed

Also ran an end-to-end probe via raw TCP against a patched Deno.serve:

Empty Host hdr   -> HTTP/1.1 200 OK | body=OK url=http://localhost:PORT/ parsed=http://localhost:PORT/
Host with just IP -> HTTP/1.1 200 OK | body=OK url=http://90.151.171.106:443/ parsed=http://90.151.171.106:443/
GET absolute URI  -> HTTP/1.1 200 OK | body=OK url=http://example.com/ parsed=http://example.com/

The broader tests/unit/serve_test.ts suite passes (160/162; the 2 failures are httpServerVsock* tests that require kernel vsock support unavailable in my WSL2 environment, unrelated to this change).

Test

Added WithEmptyHostHeader as a new row in the existing createUrlTest table in tests/unit/serve_test.ts. The helper previously used host ? ... which conflated null (omit header) with "" (send Host: with empty value); changed to host !== null ? ... so the two cases are distinguishable. Existing rows continue to use null and are unchanged.

Checklist

  • cargo build --bin deno succeeds
  • cargo clippy -p deno_http -- -D warnings clean
  • dprint check clean on the two changed files (with the pinned toolchain's rustfmt for the Rust file)
  • deno lint clean on the test file
  • All httpServerUrl* tests pass (14/14)
  • Related issue referenced (Deno.serve()'s request.url may not be a valid url #29872)

AI disclosure

I used Claude (an AI assistant) to help investigate the code path, draft the fix, and write the regression test. I reviewed every change, reproduced the bug locally, and verified the fix end-to-end before opening this PR.

An empty `Host:` header reached `req_host` as `Some("")` and was pushed
through to JS as an empty authority, which the URL builder in
`00_serve.ts` combined with the scheme and path to produce either
`http:///` (fails `new URL()`) or, worse, `http:///path` — which parses
as `http://path/`, silently promoting the path component to the URL
hostname. Treat empty Host values as if no Host header was sent so the
listener's fallback authority is used and `request.url` is always a
parseable URL that identifies the actual server.

Covered by a new row in the `createUrlTest` table in
`tests/unit/serve_test.ts`.
Copy link
Copy Markdown
Member

@bartlomieju bartlomieju left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, can you add a test?

@jeanibarz
Copy link
Copy Markdown
Author

Thanks for taking a look! A regression test is actually already included in this PR — WithEmptyHostHeader in tests/unit/serve_test.ts (around line 1067), added as a new row in the existing createUrlTest table.

The helper itself also needed a small tweak (host ? ...host !== null ? ...) because the old check conflated "omit the Host: header" with "send Host: with an empty value", and those are the two distinct code paths the fix has to cover. The new row exercises the empty-value path; existing rows still use null and exercise the omitted path.

Happy to restructure it if you'd prefer a different shape (e.g. a standalone Deno.test outside the table) — just let me know what you'd like.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deno.serve()'s request.url may not be a valid url

2 participants