Skip to content

avformat/http: 100 Continue interim response is treated as the final response; final status line and headers are returned as body data #52

Description

@ronag

Summary

When a chunked POST is sent with Expect: 100-continue — either explicitly via -send_expect_100 1, or auto-enabled whenever URL credentials are present and the auth type is still unknown — a server that replies with an interim HTTP/1.1 100 Continue response causes the HTTP client to treat that interim response as the final one. The blank line terminating the 100 Continue response sets s->end_header = 1, and that flag is never cleared until a whole new request is issued. After the body upload finishes, http_read_stream() therefore skips http_read_header(), and the final response's status line and headers (status code, Content-Length/Transfer-Encoding, Set-Cookie, etc.) are read off the socket by http_buf_read() and handed to the caller as raw payload bytes. s->http_code stays at 100, so even a 4xx/5xx final response is silently swallowed and delivered as "data". RFC 9110 §15.2.1 requires a client to be able to parse one or more interim 1xx responses prior to the final response.

Location

  • http_connect() — Expect: 100-continue decision and header read:

    FFmpeg/libavformat/http.c

    Lines 1614 to 1628 in 9a83bff

    if (post && !s->post_data) {
    if (s->send_expect_100 != -1) {
    send_expect_100 = s->send_expect_100;
    } else {
    send_expect_100 = 0;
    /* The user has supplied authentication but we don't know the auth type,
    * send Expect: 100-continue to get the 401 response including the
    * WWW-Authenticate header, or an 100 continue if no auth actually
    * is needed. */
    if (auth && *auth &&
    s->auth_state.auth_type == HTTP_AUTH_NONE &&
    s->http_code != 401)
    send_expect_100 = 1;
    }
    }
    ,

    FFmpeg/libavformat/http.c

    Lines 1667 to 1668 in 9a83bff

    if (send_expect_100 && !has_header(s->headers, "\r\nExpect: "))
    av_bprintf(&request, "Expect: 100-continue\r\n");
    ,

    FFmpeg/libavformat/http.c

    Lines 1732 to 1746 in 9a83bff

    if (post && !s->post_data && !send_expect_100) {
    /* Pretend that it did work. We didn't read any header yet, since
    * we've still to send the POST data, but the code calling this
    * function will check http_code after we return. */
    s->http_code = 200;
    err = 0;
    goto done;
    }
    /* wait for header */
    int64_t latency = av_gettime();
    err = http_read_header(h);
    latency = av_gettime() - latency;
    if (err < 0)
    goto done;
  • process_line() — blank line sets end_header, status line sets http_code:

    FFmpeg/libavformat/http.c

    Lines 1220 to 1224 in 9a83bff

    /* end of header */
    if (line[0] == '\0') {
    s->end_header = 1;
    return 0;
    }
    ,

    FFmpeg/libavformat/http.c

    Lines 1289 to 1296 in 9a83bff

    s->http_code = strtol(p, &end, 10);
    av_log(h, AV_LOG_TRACE, "http_code=%d\n", s->http_code);
    *parsed_http_code = 1;
    if ((ret = check_http_code(h, s->http_code, end)) < 0)
    return ret;
  • http_open_cnx() — 100 passes all post-connect checks:

    FFmpeg/libavformat/http.c

    Lines 497 to 541 in 9a83bff

    auth_attempts++;
    if (s->http_code == 401) {
    if ((cur_auth_type == HTTP_AUTH_NONE || s->auth_state.stale) &&
    s->auth_state.auth_type != HTTP_AUTH_NONE && auth_attempts < 4) {
    ffurl_closep(&s->hd);
    goto redo;
    } else
    goto fail;
    }
    if (s->http_code == 407) {
    if ((cur_proxy_auth_type == HTTP_AUTH_NONE || s->proxy_auth_state.stale) &&
    s->proxy_auth_state.auth_type != HTTP_AUTH_NONE && auth_attempts < 4) {
    ffurl_closep(&s->hd);
    goto redo;
    } else
    goto fail;
    }
    if ((s->http_code == 301 || s->http_code == 302 ||
    s->http_code == 303 || s->http_code == 307 || s->http_code == 308) &&
    s->new_location) {
    /* url moved, get next */
    ffurl_closep(&s->hd);
    if (redirects++ >= s->max_redirects)
    return AVERROR(EIO);
    if (!s->expires) {
    s->expires = (s->http_code == 301 || s->http_code == 308) ? INT64_MAX : -1;
    }
    if (s->expires > time(NULL) && av_dict_count(s->redirect_cache) < MAX_CACHED_REDIRECTS) {
    redirect_cache_set(s, s->location, s->new_location, s->expires);
    }
    av_free(s->location);
    s->location = s->new_location;
    s->new_location = NULL;
    s->nb_redirects++;
    /* Restart the authentication process with the new target, which
    * might use a different auth mechanism. */
    memset(&s->auth_state, 0, sizeof(s->auth_state));
    auth_attempts = 0;
    goto redo;
    }
    return 0;
  • http_shutdown() — sets end_chunked_post:
    s->end_chunked_post = 1;
  • http_read_stream() — gate that should parse the final response but doesn't:

    FFmpeg/libavformat/http.c

    Lines 1897 to 1901 in 9a83bff

    if (s->end_chunked_post && !s->end_header) {
    err = http_read_header(h);
    if (err < 0)
    return err;
    }

Details

http_connect() decides to send Expect: 100-continue for chunked POSTs (lines 1614–1628). Note that this is auto-enabled, not just opt-in:

     if (post && !s->post_data) {
        if (s->send_expect_100 != -1) {
            send_expect_100 = s->send_expect_100;
        } else {
            send_expect_100 = 0;
            /* The user has supplied authentication but we don't know the auth type,
             * send Expect: 100-continue to get the 401 response including the
             * WWW-Authenticate header, or an 100 continue if no auth actually
             * is needed. */
            if (auth && *auth &&
                s->auth_state.auth_type == HTTP_AUTH_NONE &&
                s->http_code != 401)
                send_expect_100 = 1;
        }
    }

With send_expect_100 set, the "pretend it worked" shortcut at line 1732 is skipped and http_read_header() is called before any body has been sent (line 1743):

    if (post && !s->post_data && !send_expect_100) {
        /* Pretend that it did work. ... */
        s->http_code = 200;
        err = 0;
        goto done;
    }

    /* wait for header */
    int64_t latency = av_gettime();
    err = http_read_header(h);

Step-by-step trace with a server that honors Expect (nginx and Apache emit the interim response automatically):

  1. Client sends POST ... Transfer-Encoding: chunked ... Expect: 100-continue, then calls http_read_header().
  2. Server replies HTTP/1.1 100 Continue\r\n\r\n. In process_line() the status line sets s->http_code = 100 (line 1289); check_http_code() only rejects 4xx/5xx, so 100 passes. The empty line hits lines 1220–1224:
        /* end of header */
        if (line[0] == '\0') {
            s->end_header = 1;
            return 0;
        }
    http_read_header() returns 0 with s->end_header == 1, s->line_count == 1, s->http_code == 100, s->filesize == UINT64_MAX, s->chunksize == UINT64_MAX.
  3. Back in http_open_cnx() (lines 497–541), 100 matches none of the 401/407/3xx handlers, so the open "succeeds".
  4. The caller uploads the body via http_write(); http_shutdown() sends the terminating chunk and sets s->end_chunked_post = 1 (line 2110).
  5. The caller now reads the POST reply. In http_read_stream() (line 1897):
        if (s->end_chunked_post && !s->end_header) {
            err = http_read_header(h);
    The condition is false because end_header is still 1 from the interim response. The final response is never parsed: http_buf_read() reads the final response's status line and header bytes straight off the socket and returns them as payload. The only place end_header is reset is http_connect() line 1728, i.e. only when a whole new request is issued.

Because s->http_code remains 100, an error final response (e.g. HTTP/1.1 500 Internal Server Error) is never detected — its raw header text is delivered to the caller as "data" and the transfer appears successful.

Impact

  • POST uploads against any server that emits 100 Continue (the standard behavior for nginx, Apache, and most HTTP stacks when Expect: 100-continue is received) get a corrupted response stream: the final response's status line and headers are returned as body bytes.
  • Final 4xx/5xx statuses are silently swallowed — upload failures (auth errors, server errors, payload rejections) are reported as success to the application.
  • Set-Cookie, Content-Length, Transfer-Encoding, and redirect headers of the final response are all lost, so subsequent protocol state (cookies, keep-alive reuse) is wrong.
  • Trigger surface is wider than the -send_expect_100 1 option: the header is auto-enabled for any chunked POST to a URL with embedded credentials when the auth type is not yet known (lines 1623–1626).

Severity: medium (correctness/protocol-conformance bug; no memory unsafety, but silent data corruption and error masking).

This bug is not fork-specific: the identical logic exists in upstream FFmpeg (upstream/master:libavformat/http.cs->end_header = 1 at line 1181, expect-100 auto-enable at lines 1563–1569, the s->end_chunked_post && !s->end_header gate at line 1833). It is likely worth reporting upstream as well.

Suggested fix

Per RFC 9110 §15.2, interim 1xx responses must be skipped and the client must continue to parse until the final response. Minimal fix: after reading headers in the expect-100 path of http_connect(), detect an interim response and reset the per-response parse state so http_read_stream() parses the real final response once the chunked POST has been finished. Resetting s->line_count is required so the final status line is parsed as a status line (line_count == 0) rather than ignored as a malformed header.

--- a/libavformat/http.c
+++ b/libavformat/http.c
@@ -1741,6 +1741,16 @@ static int http_connect(URLContext *h, const char *path, const char *local_path
     /* wait for header */
     int64_t latency = av_gettime();
     err = http_read_header(h);
     latency = av_gettime() - latency;
     if (err < 0)
         goto done;
 
+    if (s->http_code >= 100 && s->http_code < 200) {
+        /* RFC 9110 section 15.2: interim response (e.g. "100 Continue"
+         * elicited by Expect: 100-continue). The final response status
+         * line and headers only arrive after the request body has been
+         * sent; reset the header parse state so http_read_stream()
+         * reads them once the chunked POST has been finished. */
+        s->end_header = 0;
+        s->line_count = 0;
+    }
+
     s->nb_requests++;

With this change, after http_shutdown() sets end_chunked_post, the gate at line 1897 (s->end_chunked_post && !s->end_header) becomes true and http_read_header() parses the final response, including its status code, so errors propagate correctly. Servers that skip the interim response and answer directly with a final status (which is permitted) are unaffected, since http_code will not be 1xx.

For completeness, 1xx responses can in principle also precede the final response on non-POST requests (e.g. 103 Early Hints). A follow-up hardening would be to loop inside http_read_header(): on seeing a 1xx final blank line outside of the expect-100 POST path, reset s->line_count = 0; s->end_header = 0; and continue parsing the next status line until a non-1xx response is read.

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