Skip to content

fix: return 200 from HEAD on the proxy root index - #25

Merged
krokicki merged 1 commit into
mainfrom
fix-head-root-index
Jul 29, 2026
Merged

fix: return 200 from HEAD on the proxy root index#25
krokicki merged 1 commit into
mainfrom
fix-head-root-index

Conversation

@neomorphic

Copy link
Copy Markdown
Member

Problem

HEAD / returns 404 NoSuchBucket while GET / returns the 200 index page for the same URL:

$ curl -sSI https://s3.janelia.org/
HTTP/2 404
content-type: application/xml
content-length: 174

$ curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' https://s3.janelia.org/
200 text/html; charset=utf-8

Reproduces on HTTP/1.1, with browser User-Agent/Accept headers, and directly against the backend, so it is the app and not nginx or HTTP/2.

The 404 body is a NoSuchBucket error with an empty bucket name. Byte math confirms it — a bucket name of 30 characters yields 204 bytes, and 204 - 30 = 174:

$ curl -sS https://s3.janelia.org/definitely-not-a-bucket-xyz123   # content-length: 204
<Error><Code>NoSuchBucket</Code>...<BucketName>definitely-not-a-bucket-xyz123</BucketName></Error>

Root cause

target_dispatcher (GET) special-cases an empty target name as "serve the proxy index":

if not target_name or (is_virtual and target_name=='www'):
    # Return target index

head_object has no such case, so the same empty target name falls into the missing-bucket path:

target_name, target_path, _ = get_target(request, path)
if not target_name:
    return get_nosuchbucket_response('')

The two handlers therefore disagree about whether / exists. RFC 9110 §9.3.2 requires HEAD to return the same status and header fields GET would have sent.

Fix

Mirror target_dispatcher's root condition in head_object, including the virtual www host, and reply 200 with the media type Accept would have negotiated — _prefers_html is reused so HEAD and GET cannot drift apart.

Vary: Accept is set for the same reason as #23 / #24: / serves two representations, and nginx's proxy_cache stores HEAD responses too, so the variants must be kept apart. That PR covers the GET fork points; HEAD is outside its scope, and the two do not overlap textually.

The route path also gains its missing leading slash so it matches the GET route. get_target already does removeprefix('/'), so that part is behavior-neutral.

Testing

test_head_root_index asserts the status, the negotiated content type, and Vary for both Accept types, and compares HEAD's status and content type against GET's on the same URL. Verified it fails without the app change (assert 404 == 200) and passes with it.

Full suite: 71 passed.

Out of scope

HEAD /{bucket}/ returns content-type: application/xml unconditionally, even though GET /{bucket}/ serves HTML to browsers. Pre-existing and untested, so left alone here — happy to follow up if you want HEAD to negotiate there too.

🤖 Generated with Claude Code

HEAD / returned 404 NoSuchBucket with an empty <BucketName> while GET /
returned the 200 index page, because head_object treated an empty target
name as a missing bucket rather than the root index that target_dispatcher
serves.

Mirror target_dispatcher's root condition (including the virtual 'www'
host) and reply 200 with the media type Accept would have negotiated, plus
Vary: Accept so shared caches keep the HTML and XML variants apart.

Also add the leading slash to the route path so it matches the GET route.
get_target already strips it, so that part is behavior-neutral.
@krokicki

Copy link
Copy Markdown
Member

Nice catch, thanks!

@krokicki
krokicki merged commit 4033582 into main Jul 29, 2026
5 checks passed
@krokicki
krokicki deleted the fix-head-root-index branch July 29, 2026 20:38
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.

2 participants