Skip to content

avformat/http: Content-Length after Transfer-Encoding: chunked silently truncates response body (header-order dependent) #50

Description

@ronag

Summary

process_line() in libavformat/http.c honors whichever of Content-Length and Transfer-Encoding: chunked arrives first in the response headers, instead of letting Transfer-Encoding take precedence as required by RFC 9112 section 6.3. When a server sends Transfer-Encoding: chunked followed by a (bogus or stale) Content-Length, the Content-Length value is accepted into s->filesize and later used as an EOF boundary by http_buf_read(). The chunked body is then silently truncated at that byte count: the read loop returns AVERROR_EOF mid-chunk and the remainder of the body is discarded with no error reported to the caller. If the same two headers arrive in the opposite order, the code behaves correctly — proving the order dependence.

Location

  • process_line() — Content-Length branch:

    FFmpeg/libavformat/http.c

    Lines 1312 to 1314 in 9a83bff

    } else if (!av_strcasecmp(tag, "Content-Length") &&
    s->filesize == UINT64_MAX) {
    s->filesize = strtoull(p, NULL, 10);
  • process_line() — Transfer-Encoding branch:

    FFmpeg/libavformat/http.c

    Lines 1321 to 1324 in 9a83bff

    } else if (!av_strcasecmp(tag, "Transfer-Encoding") &&
    !av_strncasecmp(p, "chunked", 7)) {
    s->filesize = UINT64_MAX;
    s->chunksize = 0;
  • http_read_header() — chunksize sentinel initialization:
    s->chunksize = UINT64_MAX;
  • http_buf_read() — where the truncation manifests:

    FFmpeg/libavformat/http.c

    Lines 1824 to 1827 in 9a83bff

    uint64_t file_end = s->end_off ? s->end_off : s->filesize;
    uint64_t target_end = s->range_end ? s->range_end : file_end;
    if ((!s->willclose || s->chunksize == UINT64_MAX) && s->off >= file_end)
    return AVERROR_EOF;

Details

Header parsing in process_line():

        } else if (!av_strcasecmp(tag, "Content-Length") &&
                   s->filesize == UINT64_MAX) {
            s->filesize = strtoull(p, NULL, 10);
        } else if (!av_strcasecmp(tag, "Content-Range")) {
            ...
        } else if (!av_strcasecmp(tag, "Transfer-Encoding") &&
                   !av_strncasecmp(p, "chunked", 7)) {
            s->filesize  = UINT64_MAX;
            s->chunksize = 0;

EOF check in http_buf_read() (once the internal header buffer is drained):

    } else {
        uint64_t file_end   = s->end_off   ? s->end_off   : s->filesize;
        uint64_t target_end = s->range_end ? s->range_end : file_end;
        if ((!s->willclose || s->chunksize == UINT64_MAX) && s->off >= file_end)
            return AVERROR_EOF;

Step-by-step trace (trigger: an HTTP/1.1 keep-alive response carrying both headers, with Transfer-Encoding first):

  1. Before the request, s->filesize is initialized to UINT64_MAX (line 816), and http_read_header() sets s->chunksize = UINT64_MAX (line 1483) before parsing the response headers.
  2. The server responds with headers in this order:
    HTTP/1.1 200 OK
    Transfer-Encoding: chunked
    Content-Length: 100
    
  3. The Transfer-Encoding branch (lines 1321-1324) runs first: s->filesize = UINT64_MAX; s->chunksize = 0; — chunked mode is correctly enabled.
  4. The Content-Length branch (lines 1312-1314) then runs. Its only guard is s->filesize == UINT64_MAX, which passes because the Transfer-Encoding branch just reset it. s->filesize becomes 100 even though the message is chunked. (In the reverse header order, the Transfer-Encoding branch runs last and wipes the Content-Length, which is the correct outcome — hence the order dependence.)
  5. During body reads, http_buf_read() correctly follows chunk framing (s->chunksize != UINT64_MAX), but once the internal buffer is empty it evaluates lines 1824-1827: file_end = s->filesize = 100, and for a keep-alive response s->willclose == 0, so !s->willclose is true regardless of chunked state. As soon as the decoded offset s->off reaches 100, the function returns AVERROR_EOF mid-chunk.
  6. The caller sees a clean EOF at 100 bytes; the rest of the chunked body is silently dropped. No error or warning is produced.

Note the asymmetric guard: the EOF condition (!s->willclose || s->chunksize == UINT64_MAX) keeps the check enabled for non-closing connections even when chunked, whereas the premature-end check just below it (lines 1831-1838) is correspondingly suppressed, so the truncation is never flagged.

Impact

Severity: medium.

  • Silent data truncation: demuxers receive a clean EOF mid-stream and return partial media data with no error. Depending on the container this surfaces as truncated playback, missing samples/packets, or downstream "Invalid data" errors far from the root cause.
  • Trigger is server-controlled: any misbehaving or misconfigured origin/proxy/CDN that emits both headers in this order (a real-world occurrence, typically a stale Content-Length left in place when chunking is applied) triggers the bug. RFC 9112 explicitly anticipates such messages and requires Transfer-Encoding to win.
  • Not directly exploitable for memory corruption, but RFC 9112 treats CL/TE conflicts as smuggling-adjacent and mandates ignoring Content-Length precisely to avoid this class of desync.

This code is inherited verbatim from upstream FFmpeg — the identical Content-Length/Transfer-Encoding branches and http_buf_read() EOF logic exist in upstream/master (git show upstream/master:libavformat/http.c, lines 1271-1283). It is worth reporting upstream (trac / ffmpeg-devel) as well.

Suggested fix

In the Content-Length branch, additionally require that no chunked Transfer-Encoding has been seen. s->chunksize is reset to the UINT64_MAX sentinel at the start of every header parse (line 1483) and is only set to 0 by the Transfer-Encoding branch, so it is a reliable "chunked seen" flag at this point:

--- a/libavformat/http.c
+++ b/libavformat/http.c
@@ -1310,7 +1310,8 @@ static int process_line(URLContext *h, char *line, int line_count, int *parsed_
             if ((ret = parse_location(s, p)) < 0)
                 return ret;
         } else if (!av_strcasecmp(tag, "Content-Length") &&
-                   s->filesize == UINT64_MAX) {
+                   s->filesize == UINT64_MAX &&
+                   s->chunksize == UINT64_MAX) {
             s->filesize = strtoull(p, NULL, 10);
         } else if (!av_strcasecmp(tag, "Content-Range")) {
             parse_content_range(h, p);

This makes the parser ignore Content-Length whenever chunked encoding is in effect, regardless of header order, matching RFC 9112 section 6.3. (The Content-Range-derived filesize path at lines 1521-1524 is unaffected, as it intentionally applies even with chunked encoding.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghttplibavformat/http.cupstreamAlso present in upstream FFmpeg

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions