✓ProveIt
Examples / Report #3972385 Saved analysis

CVE-2026-82209: domain-scoped PSL domain cookie

curl · 2026-08-2620.7s analysis · $0.034 in model calls

What the report claims

curl incorrectly stores a Set-Cookie with Domain exactly equal to a public suffix as domain-scoped, causing the cookie to be sent to sibling hosts under the same public suffix.

Compare with curl's recorded outcome

Known vulnerability

A real vulnerability: CVE-2026-82209 domain-scoped PSL domain cookie

Shown for comparison after analysis. The pipeline did not see this label.

ProveIt's finding

Code supports the claim

The code supports the reported flaw. A maintainer should review its impact and decide what to do next.

Why this finding?
  • parse_domain() unconditionally sets tailmatch=TRUE for all non-IP Domain attributes (lib/cookie.c:511-512)
  • is_public_suffix() accepts exact-match public suffix cookies via psl_is_cookie_domain_acceptable() but does not clear tailmatch (lib/cookie.c:840, 856-860)
  • Curl_cookie_getlist() then sends the cookie to sibling domains via cookie_tailmatch() suffix matching (lib/cookie.c:1344-1347, 95-98)
  • This violates the PSL security boundary: cookies set on a public suffix apex with explicit Domain=apex leak to sibling domains
  • The suggested fix (clearing tailmatch after PSL acceptance for exact-match public suffix) addresses the root cause
  • libpsl is enabled by default in most distribution packages per the report, so this affects typical curl builds
What still needs checking
  • Direct confirmation of libpsl's exact-match behavior from libpsl documentation or source
How this analysis ran 5 claims · 9 citations verified

Checked against curl tested commit c2676bf9e639 (c2676bf9e639). Test evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.

1 · Grounding
partially grounded
no model · 16 references checked out, 1 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
5 claims · 2344 tokens · $0.0003
3 · Evidence
10 code snippets
real curl source at c2676bf9e639 · 6 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
21804 tokens · 17.9s · $0.0334
5 · Receipts
9 of 9 citations verified
every citation must point at code the judge was shown
Review a draft reply to the reporter

Edit and verify before sending.

Thank you for the detailed report. The code confirms the flaw: parse_domain() sets tailmatch=TRUE unconditionally (lib/cookie.c:511), is_public_suffix() accepts exact-match public-suffix cookies via libpsl but leaves tailmatch=TRUE (lib/cookie.c:840, 856), and Curl_cookie_getlist() then leaks the cookie to sibling domains via cookie_tailmatch() (lib/cookie.c:1344-1347, 95-98). Your suggested fix to clear tailmatch after PSL acceptance for exact-match public suffixes is sound. We'll investigate the libpsl contract and apply a fix. One question: do you have a reference to libpsl's documented behavior for exact-match cases?

Inspect all claims and source code 5 claims · 10 code excerpts

Report claims

C1flaw supported

curl stores a Set-Cookie with Domain exactly equal to a public suffix as domain-scoped (tailmatch=TRUE) instead of host-only

Read the reasoning

The code shows parse_domain() sets tailmatch=TRUE for all non-IP domains (line 511-512). When the domain is a public suffix and exactly matches the request host, libpsl returns acceptable, is_public_suffix() allows the cookie (returns FALSE), but tailmatch remains TRUE, making the cookie domain-scoped instead of host-only.

C2location supported

the flaw occurs in lib/cookie.c in parse_domain() where tailmatch is unconditionally set for non-IP Domain attributes

Read the reasoning

The flaw originates in parse_domain() at line 511-512 where tailmatch is unconditionally set for non-IP Domain attributes, with no exception for exact-match public suffix cases.

C3trigger not verifiable

a Set-Cookie header with Domain attribute exactly matching the request host (e.g., github.io) is accepted by psl_is_cookie_domain_acceptable()

Read the reasoning

The code calls psl_is_cookie_domain_acceptable() but the provided external sources (E6) discuss Microsoft Purview EDM, not libpsl behavior. The report's claim about libpsl returning 'acceptable' for exact equality is plausible given the code structure but not confirmed by the provided excerpts.

C4impact supported

the cookie is sent to any sibling host (e.g., attacker.github.io) on subsequent requests or redirects

Read the reasoning

When tailmatch=TRUE, Curl_cookie_getlist() uses cookie_tailmatch() which matches sibling domains (e.g., 'github.io' cookie domain matches 'attacker.github.io' host because 'github.io' is a suffix with a preceding dot).

C5reproduction supported

using curl with --resolve to map github.io and attacker.github.io to localhost, making a request that sets a Domain=github.io cookie and follows the redirect to attacker.github.io

Read the reasoning

The reproduction steps exercise the exact code path: setting a Domain=github.io cookie from github.io (public suffix apex), which is accepted by libpsl, stored with tailmatch=TRUE, then sent to attacker.github.io on redirect via cookie_tailmatch() suffix matching.

Deterministic checks that failed

  • quote only 0/4 quoted lines appear anywhere in curl at c2676bf9e639

What the report says about other projects

not addressed RFC 6265 §5.3 requires exact-match Domain case to produce a host-only cookie

not addressed libpsl returns 'acceptable' for exact equality between request host and public suffix domain

Read the original report (excerpt)

Full report on HackerOne ↗

## Summary When libpsl is enabled and a response host is itself a public suffix (e.g. `github.io`), a `Set-Cookie` header whose `Domain` attribute exactly equals that host is accepted by `psl_is_cookie_domain_acceptable()` but stored with `tailmatch = TRUE`. RFC 6265 §5.3 requires this exact-match case to produce a host-only cookie. Because curl keeps it domain-scoped, the cookie is sent to any sibling host (e.g. `attacker.github.io`) on a subsequent request or redirect. ## Affected versions The vulnerable code path exists in at least curl 8.21.0 (latest GA release) and the current 8.22.0-DEV tree. The `parse_domain()` logic has been unchanged across many prior releases. libpsl must be enabled at build time (`--with-libpsl`), which most distribution packages do. ## Root cause In `lib/cookie.c`, `parse_domain()` unconditionally sets `co->tailmatch = TRUE` for every explicit non-IP `Domain` attribute (line ~512): ```c if(!is_ip) co->tailmatch = TRUE; ``` Later, `is_public_suffix()` calls `psl_is_cookie_domain_acceptable(psl, "github.io", "github.io")`. libpsl intentionally returns "acceptable" for exact equality — the caller is expected to handle the host-only conversion. curl never does. When `Curl_cookie_getlist()` builds the outgoing `Cookie` header, it calls `cookie_tailmatch("github.io", 9, "attacker.github.io")` which returns `TRUE` (suffix match with a preceding `.`), so the cookie leaks. ## Reproduction 1. Build curl at commit c2676bf9e639e31e44b12a2cc82cf2046231b056 (or any version from 8.21.0 onward) on a 64-bit Linux or macOS host, ensuring libpsl is enabled (--with-libpsl or -DCURL_USE_LIBPSL=ON). Confirm the build includes libpsl by checking that curl --version output contains libpsl. 2. Start a local HTTP server on port 8080 that serves two endpoints: /set-domain — responds with 302, sets Set-Cookie: sid=SECRET; Domain=github.io; Path=/, and redirects to http://attacker.github.io:8080/sink. /sink — responds with 200 and echoes back the received Cookie header value in the response body. 3. Map the test hostnames to localhost using curl's --resolve flag: github.io:8080:127.0.0.1 attacker.github.io:8080:127.0.0.1 4. Run the vulnerable request — make a request to http://github.io:8080/set-domain with redirect following enabled (-L) and a cookie jar (-c /tmp/domain.jar): ``` curl -sS -L --noproxy '*' \ --resolve github.io:8080:127.0 …

Source code

lib/cookie.clines 1314–1414 · definition of Curl_cookie_getlist
C4: Curl_cookie_getlist() checks (co->tailmatch && !is_ip && cookie_tailmatch(co->domain, strlen(co->domain), host))
C5: The reproduction path follows the code flow: parse_domain() sets tailmatch=TRUE, is_public_suffix() accepts via libpsl, Curl_cookie_getlist() sends cookie to sibling via cookie_tailmatch()
lib/cookie.clines 73–100 · definition of cookie_tailmatch
C4: cookie_tailmatch() returns TRUE when hostname_len == cookie_domain_len (exact match) OR when preceding character is '.' (suffix match with subdomain)
C4: cookie_tailmatch() returns TRUE for 'attacker.github.io' host with 'github.io' cookie domain (suffix match with preceding '.')
lib/cookie.clines 800–871 · definition of is_public_suffix
C1: is_public_suffix() calls psl_is_cookie_domain_acceptable() and accepts the cookie when it returns acceptable (returns FALSE), leaving tailmatch=TRUE
C3: is_public_suffix() calls acceptable = psl_is_cookie_domain_acceptable(psl, lcase, lcookie);
C1: is_public_suffix() returns FALSE (do not reject) when acceptable is TRUE, so the cookie is stored with tailmatch=TRUE
lib/cookie.clines 474–525 · definition of parse_domain
C1: parse_domain() unconditionally sets co->tailmatch = TRUE for every explicit non-IP Domain attribute
C2: parse_domain() contains: if(!is_ip) co->tailmatch = TRUE;
lib/vdns/hostip.clines 336–342 · definition of tailmatch
Show 7 lines (not cited by the judge)
336static bool tailmatch(const char *full, size_t flen,
337 const char *part, size_t plen)
338{
339 if(plen > flen)
340 return FALSE;
341 return curl_strnequal(part, &full[flen - plen], plen);
342}
lib/curl_setup.hlines 1049–1049 · definition of TRUE
Show 1 lines (not cited by the judge)
1049#define TRUE true
lib/cookie.clines 988–1096 · definition of Curl_cookie_add
Show 109 lines (not cited by the judge)
988CURLcode Curl_cookie_add(struct Curl_easy *data,
989 struct CookieInfo *ci,
990 const char *lineptr, /* first character of the line */
991 const char *domain, /* default domain */
992 const char *path, /* full path used when this
993 cookie is set, used to get
994 default path for the cookie
995 unless set */
996 const int flags)
997{
998 struct Cookie comem;
999 struct Cookie *co;
1000 size_t myhash;
1001 CURLcode result;
1002 bool replaces = FALSE;
1003 bool okay;
1004
1005 DEBUGASSERT(data);
1006 DEBUGASSERT(MAX_SET_COOKIE_AMOUNT <= 255); /* counter is an unsigned char */
1007 if(data->req.setcookies >= MAX_SET_COOKIE_AMOUNT)
1008 return CURLE_OK; /* silently ignore */
1009
1010 co = &comem;
1011 memset(co, 0, sizeof(comem));
1012
1013 if(flags & COOKIE_HTTPHEADER)
1014 result = parse_cookie_header(data, co, ci, &okay,
1015 lineptr, domain, path, flags & COOKIE_SECURE);
1016 else
1017 result = parse_netscape(co, ci, &okay, lineptr, flags & COOKIE_SECURE);
1018
1019 if(result || !okay)
1020 goto fail;
1021
1022 if(co->prefix_secure && !co->secure)
1023 /* The __Secure- prefix only requires that the cookie be set secure */
1024 goto fail;
1025
1026 if(co->prefix_host) {
1027 /*
1028 * The __Host- prefix requires the cookie to be secure, have a "/" path
1029 * and not have a domain set.
1030 */
1031 if(co->secure && co->path && !strcmp(co->path, "/") && !co->tailmatch)
1032 ;
1033 else
1034 goto fail;
1035 }
1036
1037 if(!ci->running && /* read from a file */
1038 ci->newsession && /* clean session cookies */
1039 !co->expires) /* this is a session cookie */
1040 goto fail;
1041
1042 co->livecookie = ci->running;
1043 co->creationtime = ++ci->lastct;
1044
1045
1046 if(!(flags & COOKIE_NOEXPIRE))
1047 remove_expired(ci);
1048
1049 if(!(flags & COOKIE_NOPSL) && is_public_suffix(data, co, domain))
1050 goto fail;
1051
1052 /*
1053 * Now we have parsed the incoming line, we must now check if this supersedes
1054 * an already existing cookie, which it may if the previous have the same
1055 * domain and path as this.
1056 */
1057 if(!replace_existing(data, co, ci, flags & COOKIE_SECURE, &replaces))
1058 goto fail;
1059
1060 /* clone the stack struct into heap */
1061 co = curlx_memdup(&comem, sizeof(comem));
1062 if(!co) {
1063 co = &comem;
1064 result = CURLE_OUT_OF_MEMORY;
1065 goto fail; /* bail out if we are this low on memory */
1066 }
1067
1068 /* add this cookie to the list */
1069 myhash = cookiehash(co->domain);
1070 Curl_llist_append(&ci->cookielist[myhash], co, &co->node);
1071
1072 if(ci->running)
1073 /* Only show this when NOT reading the cookies from a file */
1074 infof(data, "%s cookie %s=\"%s\" for domain %s, path %s, "
1075 "expire %" FMT_OFF_T,
1076 replaces ? "Replaced" : "Added", co->name, co->value,
1077 co->domain, co->path, co->expires);
1078
1079 if(!replaces)
1080 ci->numcookies++; /* one more cookie in the jar */
1081
1082 /*
1083 * Now that we have added a new cookie to the jar, update the expiration
1084 * tracker in case it is the next one to expire.
1085 */
1086 if(co->expires && (co->expires < ci->next_expiration))
1087 ci->next_expiration = co->expires;
1088
1089 if(flags & COOKIE_HTTPHEADER)
1090 data->req.setcookies++;
1091
1092 return result;
1093fail:
1094 freecookie(co, FALSE);
1095 return result;
1096}
lib/cookie.c@curl-8_21_0lines 974–1084 · definition of Curl_cookie_add as in release curl-8_21_0; differs from master; commits that changed it since: c04189523c cookie: refuse to load cookies set against a PSL domain
Show 111 lines (not cited by the judge)
974CURLcode Curl_cookie_add(
975 struct Curl_easy *data,
976 struct CookieInfo *ci,
977 bool httpheader, /* TRUE if HTTP header-style line */
978 bool noexpire, /* if TRUE, skip remove_expired() */
979 const char *lineptr, /* first character of the line */
980 const char *domain, /* default domain */
981 const char *path, /* full path used when this cookie is set, used
982 to get default path for the cookie unless set */
983 bool secure) /* TRUE if connection is over secure origin */
984{
985 struct Cookie comem;
986 struct Cookie *co;
987 size_t myhash;
988 CURLcode result;
989 bool replaces = FALSE;
990 bool okay;
991
992 DEBUGASSERT(data);
993 DEBUGASSERT(MAX_SET_COOKIE_AMOUNT <= 255); /* counter is an unsigned char */
994 if(data->req.setcookies >= MAX_SET_COOKIE_AMOUNT)
995 return CURLE_OK; /* silently ignore */
996
997 co = &comem;
998 memset(co, 0, sizeof(comem));
999
1000 if(httpheader)
1001 result = parse_cookie_header(data, co, ci, &okay,
1002 lineptr, domain, path, secure);
1003 else
1004 result = parse_netscape(co, ci, &okay, lineptr, secure);
1005
1006 if(result || !okay)
1007 goto fail;
1008
1009 if(co->prefix_secure && !co->secure)
1010 /* The __Secure- prefix only requires that the cookie be set secure */
1011 goto fail;
1012
1013 if(co->prefix_host) {
1014 /*
1015 * The __Host- prefix requires the cookie to be secure, have a "/" path
1016 * and not have a domain set.
1017 */
1018 if(co->secure && co->path && !strcmp(co->path, "/") && !co->tailmatch)
1019 ;
1020 else
1021 goto fail;
1022 }
1023
1024 if(!ci->running && /* read from a file */
1025 ci->newsession && /* clean session cookies */
1026 !co->expires) /* this is a session cookie */
1027 goto fail;
1028
1029 co->livecookie = ci->running;
1030 co->creationtime = ++ci->lastct;
1031
1032 /*
1033 * Now we have parsed the incoming line, we must now check if this supersedes
1034 * an already existing cookie, which it may if the previous have the same
1035 * domain and path as this.
1036 */
1037
1038 /* remove expired cookies */
1039 if(!noexpire)
1040 remove_expired(ci);
1041
1042 if(is_public_suffix(data, co, domain))
1043 goto fail;
1044
1045 if(!replace_existing(data, co, ci, secure, &replaces))
1046 goto fail;
1047
1048 /* clone the stack struct into heap */
1049 co = curlx_memdup(&comem, sizeof(comem));
1050 if(!co) {
1051 co = &comem;
1052 result = CURLE_OUT_OF_MEMORY;
1053 goto fail; /* bail out if we are this low on memory */
1054 }
1055
1056 /* add this cookie to the list */
1057 myhash = cookiehash(co->domain);
1058 Curl_llist_append(&ci->cookielist[myhash], co, &co->node);
1059
1060 if(ci->running)
1061 /* Only show this when NOT reading the cookies from a file */
1062 infof(data, "%s cookie %s=\"%s\" for domain %s, path %s, "
1063 "expire %" FMT_OFF_T,
1064 replaces ? "Replaced" : "Added", co->name, co->value,
1065 co->domain, co->path, co->expires);
1066
1067 if(!replaces)
1068 ci->numcookies++; /* one more cookie in the jar */
1069
1070 /*
1071 * Now that we have added a new cookie to the jar, update the expiration
1072 * tracker in case it is the next one to expire.
1073 */
1074 if(co->expires && (co->expires < ci->next_expiration))
1075 ci->next_expiration = co->expires;
1076
1077 if(httpheader)
1078 data->req.setcookies++;
1079
1080 return result;
1081fail:
1082 freecookie(co, FALSE);
1083 return result;
1084}
lib/strequal.clines 76–84 · definition of curl_strequal
Show 9 lines (not cited by the judge)
76int curl_strequal(const char *s1, const char *s2)
77{
78 if(s1 && s2)
79 /* both pointers point to something then compare them */
80 return casecompare(s1, s2);
81
82 /* if both pointers are NULL then treat them as equal */
83 return NULL == s1 && NULL == s2;
84}
lib/cookie.c@curl-8_21_0lines 803–857 · definition of is_public_suffix as in release curl-8_21_0; differs from master; commits that changed it since: 85c8f6f3a5 cookie: allow loading cookies for localhost | c04189523c cookie: refuse to load cookies set against a PSL domain
Show 55 lines (not cited by the judge)
803static bool is_public_suffix(struct Curl_easy *data,
804 const struct Cookie *co,
805 const char *domain)
806{
807#ifdef USE_LIBPSL
808 /*
809 * Check if the domain is a Public Suffix and if yes, ignore the cookie. We
810 * must also check that the data handle is not NULL since the psl code will
811 * dereference it.
812 */
813 DEBUGF(infof(data, "PSL check set-cookie '%s' for domain=%s in %s",
814 co->name, co->domain, domain));
815 if(data && (domain && co->domain && !Curl_host_is_ipnum(co->domain))) {
816 bool acceptable = FALSE;
817 char lcase[256];
818 char lcookie[256];
819 size_t dlen = strlen(domain);
820 size_t clen = strlen(co->domain);
821
822 /* trim trailing dots */
823 if(dlen && (domain[dlen - 1] == '.'))
824 dlen--;
825 if(clen && (co->domain[clen - 1] == '.'))
826 clen--;
827
828 if((dlen < sizeof(lcase)) && (clen < sizeof(lcookie))) {
829 const psl_ctx_t *psl = Curl_psl_use(data);
830 if(psl) {
831 /* the PSL check requires lowercase domain name and pattern */
832 Curl_strntolower(lcase, domain, dlen);
833 lcase[dlen] = 0;
834 Curl_strntolower(lcookie, co->domain, clen);
835 lcookie[clen] = 0;
836 acceptable = psl_is_cookie_domain_acceptable(psl, lcase, lcookie);
837 Curl_psl_release(data);
838 }
839 else
840 infof(data, "libpsl problem, rejecting cookie for safety");
841 }
842
843 if(!acceptable) {
844 infof(data, "cookie '%s' dropped, domain '%s' must not "
845 "set cookies for '%s'", co->name, domain, co->domain);
846 return TRUE;
847 }
848 }
849#else
850 (void)data;
851 (void)co;
852 (void)domain;
853 DEBUGF(infof(data, "NO PSL to check set-cookie '%s' for domain=%s in %s",
854 co->name, co->domain, domain));
855#endif
856 return FALSE;
857}