Skip to content

HEAD on a body over Int32::MAX answers 500: the discarded-body counter is an Int32 #803

Description

@urunsiyabend

Summary

Kemal::HeadRequestHandler swallows the body a HEAD request generates and counts the bytes so it can set Content-Length. The counter is an Int32, and Crystal checks integer overflow, so the 2 147 483 648th byte raises OverflowError. Kemal::ExceptionHandler sits below HeadRequestHandler in the chain and turns that into a 500: HEAD on a file of 2 GiB or more answers 500 where the matching GET answers 200. The threshold is exactly Int32::MAX.

Where

  • src/kemal/head_request_handler.cr:11@out_count : Int32
  • src/kemal/head_request_handler.cr:25@out_count += slice.bytesize
  • src/kemal/head_request_handler.cr:39@response.content_length = @out_count

HTTP::Server::Response#content_length already returns Int64? and content_length= takes any Int (http/server/response.cr:82-90), so the counter is the only Int32 in the path.

Reproduction

public_folder with three sparse files (truncate -s 2148000000 big.bin, -s 2147483647 atmax.bin, -s 1048576 small.bin), plus a route that writes 2048 × 1 MiB itself. Crystal 1.21.0, master 91ff784:

GET  /big.bin   -> 200, 2148000000 bytes delivered, 0.34s
HEAD /small.bin -> 200 OK, Content-Length: 1048576
HEAD /atmax.bin -> 200 OK, Content-Length: 2147483647
HEAD /big.bin   -> 500 Internal Server Error, Content-Length: 2148000000
HEAD /dyn       -> 500 Internal Server Error, Content-Length: 2146435629

One Arithmetic overflow (OverflowError) in the log per failing request. The server stays up — a control request afterwards returns 200.

No filesystem needed to hit it: 2048 writes of 1 MiB through the handler raise the same error.

The 500 carries a Content-Length

Two shapes, depending on who set the header:

  • send_file sets Content-Length before the copy and NullIO#close does not overwrite an existing one, so HEAD /big.bin is a 500 announcing a 2 GB body.
  • A route writing its own body has no explicit length, so NullIO#close sets it from the counter, frozen wherever the overflow interrupted it. GET /dyn delivers 2147483648 bytes; HEAD /dyn reports 2146435629, 1 048 019 short. A client sizing a follow-up Range from that gets it wrong.

Separate: the body is read in full to be discarded

Widening the counter leaves the other half of the design alone. Measured on a real 200 MiB file via /proc/self/io:

file size                        = 209715200 bytes
HEAD -> status=200, read from FS = 209790165 bytes   (1.0x)

So HEAD on a 20 GiB file does 20 GiB of filesystem reads. The counting can't simply be dropped — spec/head_request_handler_spec.cr:25 pins that a HEAD reports the gzip-compressed length, which isn't knowable without producing the body — and skipping the copy is only safe where the length is already known. Different change, so it's out of scope here. #798 fixed the SSE-specific case of the same discard-and-count design.

Activity

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions