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:
|
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; |
|
} |
|
} |
,
|
if (send_expect_100 && !has_header(s->headers, "\r\nExpect: ")) |
|
av_bprintf(&request, "Expect: 100-continue\r\n"); |
,
|
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:
|
/* end of header */ |
|
if (line[0] == '\0') { |
|
s->end_header = 1; |
|
return 0; |
|
} |
,
|
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:
|
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:
http_read_stream() — gate that should parse the final response but doesn't:
|
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):
- Client sends
POST ... Transfer-Encoding: chunked ... Expect: 100-continue, then calls http_read_header().
- 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.
- Back in
http_open_cnx() (lines 497–541), 100 matches none of the 401/407/3xx handlers, so the open "succeeds".
- The caller uploads the body via
http_write(); http_shutdown() sends the terminating chunk and sets s->end_chunked_post = 1 (line 2110).
- 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.c — s->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.
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 interimHTTP/1.1 100 Continueresponse causes the HTTP client to treat that interim response as the final one. The blank line terminating the100 Continueresponse setss->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 skipshttp_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 byhttp_buf_read()and handed to the caller as raw payload bytes.s->http_codestays 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
FFmpeg/libavformat/http.c
Lines 1667 to 1668 in 9a83bff
FFmpeg/libavformat/http.c
Lines 1732 to 1746 in 9a83bff
process_line()— blank line setsend_header, status line setshttp_code:FFmpeg/libavformat/http.c
Lines 1220 to 1224 in 9a83bff
FFmpeg/libavformat/http.c
Lines 1289 to 1296 in 9a83bff
http_open_cnx()— 100 passes all post-connect checks:FFmpeg/libavformat/http.c
Lines 497 to 541 in 9a83bff
http_shutdown()— setsend_chunked_post:FFmpeg/libavformat/http.c
Line 2110 in 9a83bff
http_read_stream()— gate that should parse the final response but doesn't:FFmpeg/libavformat/http.c
Lines 1897 to 1901 in 9a83bff
Details
http_connect()decides to sendExpect: 100-continuefor chunked POSTs (lines 1614–1628). Note that this is auto-enabled, not just opt-in:With
send_expect_100set, the "pretend it worked" shortcut at line 1732 is skipped andhttp_read_header()is called before any body has been sent (line 1743):Step-by-step trace with a server that honors
Expect(nginx and Apache emit the interim response automatically):POST ... Transfer-Encoding: chunked ... Expect: 100-continue, then callshttp_read_header().HTTP/1.1 100 Continue\r\n\r\n. Inprocess_line()the status line setss->http_code = 100(line 1289);check_http_code()only rejects 4xx/5xx, so 100 passes. The empty line hits lines 1220–1224:http_read_header()returns 0 withs->end_header == 1,s->line_count == 1,s->http_code == 100,s->filesize == UINT64_MAX,s->chunksize == UINT64_MAX.http_open_cnx()(lines 497–541), 100 matches none of the 401/407/3xx handlers, so the open "succeeds".http_write();http_shutdown()sends the terminating chunk and setss->end_chunked_post = 1(line 2110).http_read_stream()(line 1897):end_headeris 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 placeend_headeris reset ishttp_connect()line 1728, i.e. only when a whole new request is issued.Because
s->http_coderemains 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
100 Continue(the standard behavior for nginx, Apache, and most HTTP stacks whenExpect: 100-continueis received) get a corrupted response stream: the final response's status line and headers are returned as body bytes.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.-send_expect_100 1option: 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.c—s->end_header = 1at line 1181, expect-100 auto-enable at lines 1563–1569, thes->end_chunked_post && !s->end_headergate 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 sohttp_read_stream()parses the real final response once the chunked POST has been finished. Resettings->line_countis required so the final status line is parsed as a status line (line_count == 0) rather than ignored as a malformed header.With this change, after
http_shutdown()setsend_chunked_post, the gate at line 1897 (s->end_chunked_post && !s->end_header) becomes true andhttp_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, sincehttp_codewill 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 insidehttp_read_header(): on seeing a 1xx final blank line outside of the expect-100 POST path, resets->line_count = 0; s->end_header = 0;and continue parsing the next status line until a non-1xx response is read.