PR #24554 opened by michaelni URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24554 Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24554.patch
Fixes: cookie leak to paths sharing a name prefix Fixes: u2ys08MO8oEF Found-by: Yazan Balawneh, Cystack.ps >From 997b529c3c8c530a7424ffccfc3fbc8c160fbcd6 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Thu, 17 Sep 2026 18:53:40 +0200 Subject: [PATCH 1/5] avformat/http: match cookies against the host name, not host:port get_cookies() was handed the "host:port" string used for the Host header and compared it with the cookie Domain attribute, so a cookie carrying a Domain never matched a URL with an explicit port. RFC 6265 section 8.5 ignores ports for cookie scoping. Found during triage/review of security report u2ys08MO8oEF --- libavformat/http.c | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/libavformat/http.c b/libavformat/http.c index fff0f25e36..dcc464f969 100644 --- a/libavformat/http.c +++ b/libavformat/http.c @@ -91,6 +91,7 @@ typedef struct HTTPContext { uint8_t *post_data; int post_datalen; char *cookies; ///< holds newline (\n) delimited Set-Cookie header field values (without the "Set-Cookie: " field name) + char *host; int icy; char *icy_metadata_headers; char *icy_metadata_packet; @@ -262,6 +263,11 @@ static int http_open_cnx_internal(URLContext *h, AVDictionary **options) hostname, sizeof(hostname), &port, path1, sizeof(path1), s->location); + av_freep(&s->host); + s->host = av_strdup(hostname); + if (!s->host) + return AVERROR(ENOMEM); + av_strlcpy(tmp_host, hostname, sizeof(tmp_host)); // In case of an IPv6 address, we need to strip the Zone ID, // if any. We do it at the first % sign, as percent encoding @@ -1393,8 +1399,7 @@ static int process_line(URLContext *h, char *line, int line_count, int *parsed_h * * @return a negative value if an error condition occurred, 0 otherwise */ -static int get_cookies(HTTPContext *s, char **cookies, const char *path, - const char *domain) +static int get_cookies(HTTPContext *s, char **cookies, const char *path) { // cookie strings will look like Set-Cookie header field values. Multiple // Set-Cookie fields will result in multiple values delimited by a newline @@ -1443,12 +1448,12 @@ static int get_cookies(HTTPContext *s, char **cookies, const char *path, // if no domain in the cookie assume it applied to this request if ((e = av_dict_get(cookie_params, "domain", NULL, 0)) && e->value) { // find the offset comparison is on the min domain (b.com, not a.b.com) - int domain_offset = strlen(domain) - strlen(e->value); + int domain_offset = strlen(s->host) - strlen(e->value); if (domain_offset < 0) goto skip_cookie; // match the cookie domain - if (av_strcasecmp(&domain[domain_offset], e->value)) + if (av_strcasecmp(&s->host[domain_offset], e->value)) goto skip_cookie; } @@ -1689,7 +1694,7 @@ static int http_connect(URLContext *h, const char *path, const char *local_path, av_bprintf(&request, "Content-Type: %s\r\n", s->content_type); if (!has_header(s->headers, "\r\nCookie: ") && s->cookies) { char *cookies = NULL; - if (!get_cookies(s, &cookies, path, hoststr) && cookies) { + if (!get_cookies(s, &cookies, path) && cookies) { av_bprintf(&request, "Cookie: %s\r\n", cookies); av_free(cookies); } @@ -2141,6 +2146,7 @@ static int http_close(URLContext *h) av_dict_free(&s->redirect_cache); av_freep(&s->new_location); av_freep(&s->uri); + av_freep(&s->host); av_log(h, AV_LOG_DEBUG, "Statistics: %d connection%s, %d request%s, %d retr%s, %d reconnection%s, %d redirect%s\n", s->nb_connections, s->nb_connections == 1 ? "" : "s", -- 2.52.0 >From d38c9a04b7b2ebdddc6bbece515ae5bdadc42669 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Thu, 17 Sep 2026 18:53:41 +0200 Subject: [PATCH 2/5] avformat/http: match cookie domains only at a label boundary A cookie with Domain=example.com was sent to any host whose name merely ends in "example.com", such as evilexample.com, because get_cookies() only compared the suffix. RFC 6265 section 5.1.3 requires the domain to equal the host or to be a suffix of it preceded by a dot. Fixes: cookie leak to hosts sharing a name suffix Fixes: u2ys08MO8oEF Found-by: Yazan Balawneh, Cystack.ps --- libavformat/http.c | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/libavformat/http.c b/libavformat/http.c index dcc464f969..b7bd928526 100644 --- a/libavformat/http.c +++ b/libavformat/http.c @@ -1102,6 +1102,22 @@ static int parse_set_cookie(const char *set_cookie, AVDictionary **dict) return 0; } +static const char *cookie_domain(const AVDictionary *cookie_params) +{ + const AVDictionaryEntry *e = av_dict_get(cookie_params, "domain", NULL, 0); + const char *domain = e ? e->value + (e->value[0] == '.') : ""; + + return *domain ? domain : NULL; +} + +static int host_in_cookie_domain(const char *host, const char *domain) +{ + int offset = strlen(host) - strlen(domain); + + return offset >= 0 && !av_strcasecmp(host + offset, domain) && + (!offset || host[offset - 1] == '.'); +} + static int parse_cookie(HTTPContext *s, const char *p, AVDictionary **cookies) { AVDictionary *new_params = NULL; @@ -1421,6 +1437,7 @@ static int get_cookies(HTTPContext *s, char **cookies, const char *path) while ((cookie = av_strtok(next, "\n", &saveptr)) && !ret) { AVDictionary *cookie_params = NULL; const AVDictionaryEntry *cookie_entry, *e; + const char *domain; next = NULL; // store the cookie in a dict in case it is updated in the response @@ -1446,16 +1463,9 @@ static int get_cookies(HTTPContext *s, char **cookies, const char *path) } // if no domain in the cookie assume it applied to this request - if ((e = av_dict_get(cookie_params, "domain", NULL, 0)) && e->value) { - // find the offset comparison is on the min domain (b.com, not a.b.com) - int domain_offset = strlen(s->host) - strlen(e->value); - if (domain_offset < 0) - goto skip_cookie; - - // match the cookie domain - if (av_strcasecmp(&s->host[domain_offset], e->value)) - goto skip_cookie; - } + domain = cookie_domain(cookie_params); + if (domain && !host_in_cookie_domain(s->host, domain)) + goto skip_cookie; // if a cookie path is provided, ensure the request path is within that path e = av_dict_get(cookie_params, "path", NULL, 0); -- 2.52.0 >From daefb3e4f05d30a29c82ee6b1d6de88666125851 Mon Sep 17 00:00:00 2001 From: Yazan Balawneh <[email protected]> Date: Thu, 17 Sep 2026 18:53:42 +0200 Subject: [PATCH 3/5] avformat/http: match cookie paths only at a segment boundary Fixes: cookie leak to paths sharing a name prefix Fixes: u2ys08MO8oEF Found-by: Yazan Balawneh, Cystack.ps --- libavformat/http.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libavformat/http.c b/libavformat/http.c index b7bd928526..3d2bcfe3fa 100644 --- a/libavformat/http.c +++ b/libavformat/http.c @@ -1469,8 +1469,13 @@ static int get_cookies(HTTPContext *s, char **cookies, const char *path) // if a cookie path is provided, ensure the request path is within that path e = av_dict_get(cookie_params, "path", NULL, 0); - if (e && av_strncasecmp(path, e->value, strlen(e->value))) - goto skip_cookie; + if (e) { + size_t len = strlen(e->value); + if (av_strncasecmp(path, e->value, len) || + (len && path[len] && path[len] != '/' && path[len] != '?' && + e->value[len - 1] != '/')) + goto skip_cookie; + } // cookie parameters match, so copy the value if (!*cookies) { -- 2.52.0 >From fa73ba72c8fd79b81023d99ad2f5bf76e4db2fb6 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Thu, 17 Sep 2026 18:53:43 +0200 Subject: [PATCH 4/5] avformat/http: send cookies set without a Domain only back to their host A Set-Cookie response without a Domain attribute was stored without any host restriction, so a redirect, a keep-alive reuse or a multi-host HLS/DASH manifest replayed the cookie to every later host, leaking session cookies across origins. RFC 6265 section 5.3 makes such a cookie host-only: it is returned to the exact host that set it and nothing else. Fixes: cross-host cookie leak Fixes: u2ys08MO8oEF Found-by: Yazan Balawneh, Cystack.ps --- libavformat/http.c | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/libavformat/http.c b/libavformat/http.c index 3d2bcfe3fa..dfa36ce116 100644 --- a/libavformat/http.c +++ b/libavformat/http.c @@ -1118,12 +1118,13 @@ static int host_in_cookie_domain(const char *host, const char *domain) (!offset || host[offset - 1] == '.'); } -static int parse_cookie(HTTPContext *s, const char *p, AVDictionary **cookies) +static int parse_cookie(HTTPContext *s, const char *p, const char *host, + AVDictionary **cookies) { AVDictionary *new_params = NULL; const AVDictionaryEntry *e, *cookie_entry; const char *eql; - char *name; + char *name, *value; // ensure the cookie is parsable if (parse_set_cookie(p, &new_params)) { @@ -1171,6 +1172,7 @@ static int parse_cookie(HTTPContext *s, const char *p, AVDictionary **cookies) } } } + int host_only = host && !cookie_domain(new_params); av_dict_free(&new_params); // duplicate the cookie name (dict will dupe the value) @@ -1178,7 +1180,12 @@ static int parse_cookie(HTTPContext *s, const char *p, AVDictionary **cookies) if (!(name = av_strndup(p, eql - p))) return AVERROR(ENOMEM); // add the cookie to the dictionary - av_dict_set(cookies, name, eql, AV_DICT_DONT_STRDUP_KEY); + value = av_asprintf("%s%s%s", eql, host_only ? "; hostonly=" : "", host_only ? host : ""); + if (!value) { + av_free(name); + return AVERROR(ENOMEM); + } + av_dict_set(cookies, name, value, AV_DICT_DONT_STRDUP_KEY | AV_DICT_DONT_STRDUP_VAL); return 0; } @@ -1376,7 +1383,7 @@ static int process_line(URLContext *h, char *line, int line_count, int *parsed_h av_free(s->mime_type); s->mime_type = av_get_token((const char **)&p, ";"); } else if (!av_strcasecmp(tag, "Set-Cookie")) { - if (parse_cookie(s, p, &s->cookie_dict)) + if (parse_cookie(s, p, s->host, &s->cookie_dict)) av_log(h, AV_LOG_WARNING, "Unable to parse '%s'\n", p); } else if (!av_strcasecmp(tag, "Icy-MetaInt")) { s->icy_metaint = strtoull(p, NULL, 10); @@ -1441,7 +1448,7 @@ static int get_cookies(HTTPContext *s, char **cookies, const char *path) next = NULL; // store the cookie in a dict in case it is updated in the response - if (parse_cookie(s, cookie, &s->cookie_dict)) + if (parse_cookie(s, cookie, NULL, &s->cookie_dict)) av_log(s, AV_LOG_WARNING, "Unable to parse '%s'\n", cookie); // continue on to the next cookie if this one cannot be parsed @@ -1467,6 +1474,9 @@ static int get_cookies(HTTPContext *s, char **cookies, const char *path) if (domain && !host_in_cookie_domain(s->host, domain)) goto skip_cookie; + if ((e = av_dict_get(cookie_params, "hostonly", NULL, 0)) && av_strcasecmp(e->value, s->host)) + goto skip_cookie; + // if a cookie path is provided, ensure the request path is within that path e = av_dict_get(cookie_params, "path", NULL, 0); if (e) { -- 2.52.0 >From 7c9feab6223c1ff839121c608a9e3730b06fe022 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Thu, 17 Sep 2026 18:53:44 +0200 Subject: [PATCH 5/5] avformat/http: ignore response cookies whose Domain does not cover the host Fixes: cookie injection for foreign domains Fixes: u2ys08MO8oEF Found during triage/review of the security report --- libavformat/http.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libavformat/http.c b/libavformat/http.c index dfa36ce116..b9f609a025 100644 --- a/libavformat/http.c +++ b/libavformat/http.c @@ -1172,7 +1172,13 @@ static int parse_cookie(HTTPContext *s, const char *p, const char *host, } } } - int host_only = host && !cookie_domain(new_params); + const char *domain = host ? cookie_domain(new_params) : NULL; + int host_only = host && !domain; + if (domain && !host_in_cookie_domain(host, domain)) { + av_log(s, AV_LOG_WARNING, "Ignoring cookie for domain %s set by %s\n", domain, host); + av_dict_free(&new_params); + return 0; + } av_dict_free(&new_params); // duplicate the cookie name (dict will dupe the value) -- 2.52.0 _______________________________________________ ffmpeg-devel mailing list -- [email protected] To unsubscribe send an email to [email protected]
