cookie: refuse to load cookies set against a PSL domain

Verified by test 409

Reported-by: 1rhino2 on hackerone

Closes #22500
This commit is contained in:
Daniel Stenberg 2026-08-06 09:13:30 +02:00
parent 7db9947fcf
commit c04189523c
No known key found for this signature in database
GPG key ID: 5CC908FDB71E12C2
8 changed files with 165 additions and 61 deletions

View file

@ -47,6 +47,18 @@ future transfers to that server, likely not what you intended. To address
these issues set a domain in `Set-Cookie` (doing that includes subdomains) or
much better: use the Netscape file format.
Cookies added through this API bypass automatic Public Suffix List (PSL)
checking because the handle's internal PSL engine has not yet been initialized
when the call is made. Under normal transfer operations, PSL validation
prevents cookies from being set on broad or shared domains - such as `.com`,
`.co.uk`, or `.github.io` - which would otherwise create security
vulnerabilities by allowing unrelated subdomains to access sensitive cookie
data. Because the library skips this safety check during manual cookie
insertion, the caller assumes full responsibility for domain validation.
Applications using this interface must independently verify that the target
domain attribute represents a valid host and does not match a public suffix
before injecting the cookie into the handle.
Additionally, there are commands available that perform actions if you pass in
these exact strings:
@ -64,7 +76,9 @@ writes all known cookies to the file specified by CURLOPT_COOKIEJAR(3)
## `RELOAD`
loads all cookies from the files specified by CURLOPT_COOKIEFILE(3)
loads all cookies from the files specified by CURLOPT_COOKIEFILE(3). If
CURLOPT_COOKIESESSION(3) is enabled before this reload, it is applied to this
load operation as well and all session cookies are discarded.
# DEFAULT

View file

@ -803,17 +803,23 @@ static bool is_public_suffix(struct Curl_easy *data,
{
#ifdef USE_LIBPSL
/*
* Check if the domain is a Public Suffix and if yes, ignore the cookie. We
* must also check that the data handle is not NULL since the psl code will
* dereference it.
* Check if the domain is a Public Suffix and if yes, ignore the cookie.
* 'domain' is NULL when the cookie is loaded from file or
* CURLOPT_COOKIELIST.
*/
DEBUGASSERT(data);
DEBUGASSERT(co);
DEBUGF(infof(data, "PSL check set-cookie '%s' for domain=%s in %s",
co->name, co->domain, domain));
if(data && (domain && co->domain && !Curl_host_is_ipnum(co->domain))) {
co->name, co->domain ? co->domain : "[blank]",
domain ? domain : "[file]"));
if(!co->domain || Curl_host_is_ipnum(co->domain))
return FALSE;
else {
bool acceptable = FALSE;
char lcase[256];
char lcookie[256];
size_t dlen = strlen(domain);
size_t dlen = domain ? strlen(domain) : 0;
size_t clen = strlen(co->domain);
/* trim trailing dots */
@ -826,11 +832,17 @@ static bool is_public_suffix(struct Curl_easy *data,
const psl_ctx_t *psl = Curl_psl_use(data);
if(psl) {
/* the PSL check requires lowercase domain name and pattern */
Curl_strntolower(lcase, domain, dlen);
lcase[dlen] = 0;
Curl_strntolower(lcookie, co->domain, clen);
lcookie[clen] = 0;
acceptable = psl_is_cookie_domain_acceptable(psl, lcase, lcookie);
if(domain) {
Curl_strntolower(lcase, domain, dlen);
lcase[dlen] = 0;
acceptable = psl_is_cookie_domain_acceptable(psl, lcase, lcookie);
}
else
/* note that this PSL function returns the opposite value than
psl_is_cookie_domain_acceptable() does */
acceptable = !psl_is_public_suffix(psl, lcookie);
Curl_psl_release(data);
}
else
@ -839,7 +851,8 @@ static bool is_public_suffix(struct Curl_easy *data,
if(!acceptable) {
infof(data, "cookie '%s' dropped, domain '%s' must not "
"set cookies for '%s'", co->name, domain, co->domain);
"set cookies for '%s'", co->name,
domain ? domain : "[file]", co->domain);
return TRUE;
}
}
@ -848,7 +861,7 @@ static bool is_public_suffix(struct Curl_easy *data,
(void)co;
(void)domain;
DEBUGF(infof(data, "NO PSL to check set-cookie '%s' for domain=%s in %s",
co->name, co->domain, domain));
co->name, co->domain, domain ? domain : "[file]"));
#endif
return FALSE;
}
@ -968,16 +981,15 @@ static bool replace_existing(struct Curl_easy *data,
* IPv6 address.
*
*/
CURLcode Curl_cookie_add(
struct Curl_easy *data,
struct CookieInfo *ci,
bool httpheader, /* TRUE if HTTP header-style line */
bool noexpire, /* if TRUE, skip remove_expired() */
const char *lineptr, /* first character of the line */
const char *domain, /* default domain */
const char *path, /* full path used when this cookie is set, used
to get default path for the cookie unless set */
bool secure) /* TRUE if connection is over secure origin */
CURLcode Curl_cookie_add(struct Curl_easy *data,
struct CookieInfo *ci,
const char *lineptr, /* first character of the line */
const char *domain, /* default domain */
const char *path, /* full path used when this
cookie is set, used to get
default path for the cookie
unless set */
const int flags)
{
struct Cookie comem;
struct Cookie *co;
@ -994,11 +1006,11 @@ CURLcode Curl_cookie_add(
co = &comem;
memset(co, 0, sizeof(comem));
if(httpheader)
if(flags & COOKIE_HTTPHEADER)
result = parse_cookie_header(data, co, ci, &okay,
lineptr, domain, path, secure);
lineptr, domain, path, flags & COOKIE_SECURE);
else
result = parse_netscape(co, ci, &okay, lineptr, secure);
result = parse_netscape(co, ci, &okay, lineptr, flags & COOKIE_SECURE);
if(result || !okay)
goto fail;
@ -1026,20 +1038,19 @@ CURLcode Curl_cookie_add(
co->livecookie = ci->running;
co->creationtime = ++ci->lastct;
if(!(flags & COOKIE_NOEXPIRE))
remove_expired(ci);
if(!(flags & COOKIE_NOPSL) && is_public_suffix(data, co, domain))
goto fail;
/*
* Now we have parsed the incoming line, we must now check if this supersedes
* an already existing cookie, which it may if the previous have the same
* domain and path as this.
*/
/* remove expired cookies */
if(!noexpire)
remove_expired(ci);
if(is_public_suffix(data, co, domain))
goto fail;
if(!replace_existing(data, co, ci, secure, &replaces))
if(!replace_existing(data, co, ci, flags & COOKIE_SECURE, &replaces))
goto fail;
/* clone the stack struct into heap */
@ -1071,7 +1082,7 @@ CURLcode Curl_cookie_add(
if(co->expires && (co->expires < ci->next_expiration))
ci->next_expiration = co->expires;
if(httpheader)
if(flags & COOKIE_HTTPHEADER)
data->req.setcookies++;
return result;
@ -1120,11 +1131,11 @@ struct CookieInfo *Curl_cookie_init(void)
* Reads cookies from a local file. This is always called before any cookies
* are set. If file is "-" then STDIN is read.
*
* If 'newsession' is TRUE, discard all "session cookies" on read from file.
*
* If 'flags' has the COOKIE_NOSESSION bit set, discard all "session cookies"
* read from file.
*/
static CURLcode cookie_load(struct Curl_easy *data, const char *file,
struct CookieInfo *ci, bool newsession)
struct CookieInfo *ci, int flags)
{
FILE *handle = NULL;
CURLcode result = CURLE_OK;
@ -1133,7 +1144,7 @@ static CURLcode cookie_load(struct Curl_easy *data, const char *file,
DEBUGASSERT(data);
DEBUGASSERT(file);
ci->newsession = newsession; /* new session? */
ci->newsession = !!(flags & COOKIE_NOSESSION); /* new session? */
ci->running = FALSE; /* this is not running, this is init */
if(file && *file) {
@ -1173,8 +1184,10 @@ static CURLcode cookie_load(struct Curl_easy *data, const char *file,
curlx_str_passblanks(&lineptr);
}
result = Curl_cookie_add(data, ci, headerline, TRUE, lineptr, NULL,
NULL, TRUE);
result = Curl_cookie_add(data, ci, lineptr, NULL, NULL,
(headerline ? COOKIE_HTTPHEADER : 0) |
COOKIE_NOEXPIRE | COOKIE_SECURE |
(flags & COOKIE_NOPSL));
/* File reading cookie failures are not propagated back to the
caller because there is no way to do that */
}
@ -1199,7 +1212,8 @@ static CURLcode cookie_load(struct Curl_easy *data, const char *file,
/*
* Load cookies from all given cookie files (CURLOPT_COOKIEFILE).
*/
CURLcode Curl_cookie_loadfiles(struct Curl_easy *data)
CURLcode Curl_cookie_loadfiles(struct Curl_easy *data,
int flags)
{
CURLcode result = CURLE_OK;
struct curl_slist *list = data->state.cookielist;
@ -1212,8 +1226,7 @@ CURLcode Curl_cookie_loadfiles(struct Curl_easy *data)
else {
data->state.cookie_engine = TRUE;
while(list) {
result = cookie_load(data, list->data, data->cookies,
(bool)data->set.cookiesession);
result = cookie_load(data, list->data, data->cookies, flags);
if(result)
break;
list = list->next;

View file

@ -104,20 +104,25 @@ struct CookieInfo {
struct Curl_easy;
struct connectdata;
bool Curl_secure_context(struct Curl_easy *data, const char *host);
/*
* Add a cookie to the internal list of cookies. The domain and path arguments
* are only used if the header boolean is TRUE.
* are only used if the COOKIE_HTTPHEADER bit is set in the flags.
*/
bool Curl_secure_context(struct Curl_easy *data, const char *host);
#define COOKIE_HTTPHEADER (1<<0) /* if HTTP header-style line */
#define COOKIE_NOEXPIRE (1<<1) /* skip remove_expired() */
#define COOKIE_SECURE (1<<2) /* connection is over secure origin */
#define COOKIE_NOPSL (1<<3) /* skip PSL check */
#define COOKIE_NOSESSION (1<<6) /* drop session cookies */
CURLcode Curl_cookie_add(struct Curl_easy *data,
struct CookieInfo *ci,
bool httpheader,
bool noexpire,
const char *lineptr,
const char *domain,
const char *path,
bool secure) WARN_UNUSED_RESULT;
const int flags) WARN_UNUSED_RESULT;
CURLcode Curl_cookie_getlist(struct Curl_easy *data,
bool *okay, const char *host,
struct Curl_llist *list) WARN_UNUSED_RESULT;
@ -126,7 +131,7 @@ void Curl_cookie_clearsess(struct CookieInfo *ci);
#if defined(CURL_DISABLE_HTTP) || defined(CURL_DISABLE_COOKIES)
#define Curl_cookie_list(x) NULL
#define Curl_cookie_loadfiles(x) CURLE_OK
#define Curl_cookie_loadfiles(x, y) CURLE_OK
#define Curl_cookie_init() NULL
#define Curl_cookie_run(x) Curl_nop_stmt
#define Curl_cookie_cleanup(x) Curl_nop_stmt
@ -136,7 +141,8 @@ void Curl_flush_cookies(struct Curl_easy *data, bool cleanup);
void Curl_cookie_cleanup(struct CookieInfo *ci);
struct CookieInfo *Curl_cookie_init(void);
struct curl_slist *Curl_cookie_list(struct Curl_easy *data);
CURLcode Curl_cookie_loadfiles(struct Curl_easy *data) WARN_UNUSED_RESULT;
CURLcode Curl_cookie_loadfiles(struct Curl_easy *data,
int flags) WARN_UNUSED_RESULT;
void Curl_cookie_run(struct Curl_easy *data);
#endif

View file

@ -3584,11 +3584,13 @@ static CURLcode http_header_s(struct Curl_easy *data,
* real peer hostname. */
const char *host = data->req.cookiehost ?
data->req.cookiehost : data->state.origin->hostname;
const bool secure_context = Curl_secure_context(data, host);
const unsigned char secure_context = Curl_secure_context(data, host) ?
COOKIE_SECURE : 0;
CURLcode result;
Curl_share_lock(data, CURL_LOCK_DATA_COOKIE, CURL_LOCK_ACCESS_SINGLE);
result = Curl_cookie_add(data, data->cookies, TRUE, FALSE, v, host,
data->state.up.path, secure_context);
result = Curl_cookie_add(data, data->cookies, v, host,
data->state.up.path,
COOKIE_HTTPHEADER | secure_context);
Curl_share_unlock(data, CURL_LOCK_DATA_COOKIE);
return result;
}

View file

@ -1527,7 +1527,9 @@ static CURLcode cookielist(struct Curl_easy *data, const char *ptr)
}
else if(curl_strequal(ptr, "RELOAD")) {
/* reload cookies from file */
return Curl_cookie_loadfiles(data);
return Curl_cookie_loadfiles(data, COOKIE_NOPSL |
(data->set.cookiesession ?
COOKIE_NOSESSION : 0));
}
else {
if(!data->cookies) {
@ -1542,15 +1544,20 @@ static CURLcode cookielist(struct Curl_easy *data, const char *ptr)
if(strlen(ptr) > CURL_MAX_INPUT_LENGTH)
return CURLE_BAD_FUNCTION_ARGUMENT;
/* Adding these cookies without the PSL check, because the PSL is not
initialized until *perform() time, and this might be called before
that */
Curl_share_lock(data, CURL_LOCK_DATA_COOKIE, CURL_LOCK_ACCESS_SINGLE);
if(checkprefix("Set-Cookie:", ptr))
/* HTTP Header format line */
result = Curl_cookie_add(data, data->cookies, TRUE, FALSE, ptr + 11,
NULL, NULL, TRUE);
result = Curl_cookie_add(data, data->cookies, ptr + 11,
NULL, NULL,
COOKIE_HTTPHEADER | COOKIE_SECURE |
COOKIE_NOPSL);
else
/* Netscape format line */
result = Curl_cookie_add(data, data->cookies, FALSE, FALSE, ptr, NULL,
NULL, TRUE);
result = Curl_cookie_add(data, data->cookies, ptr, NULL,
NULL, COOKIE_SECURE | COOKIE_NOPSL);
Curl_share_unlock(data, CURL_LOCK_DATA_COOKIE);
}
return result;

View file

@ -525,7 +525,9 @@ CURLcode Curl_pretransfer(struct Curl_easy *data)
data->state.infilesize = 0;
/* If there is a list of cookie files to read, do it now! */
result = Curl_cookie_loadfiles(data);
result = Curl_cookie_loadfiles(data,
data->set.cookiesession ?
COOKIE_NOSESSION : 0);
if(!result)
Curl_cookie_run(data); /* activate */

View file

@ -70,7 +70,7 @@ test370 test371 test372 test373 test374 test375 test376 test378 test379 \
test380 test381 test383 test384 test385 test386 test387 test388 test389 \
test390 test391 test392 test393 test394 test395 test396 test397 test398 \
test399 test400 test401 test402 test403 test404 test405 test406 test407 \
test408 test410 test411 test412 test413 test414 test415 test416 \
test408 test409 test410 test411 test412 test413 test414 test415 test416 \
test417 test418 test419 test420 test421 test422 test423 test424 test425 \
test426 test427 test428 test429 test430 test431 test432 test433 test434 \
test435 test436 test437 test438 test439 test440 test441 test442 test443 \

60
tests/data/test409 Normal file
View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="US-ASCII"?>
<testcase>
<info>
<keywords>
HTTP
cookies
</keywords>
</info>
# Server-side
<reply>
<data crlf="headers">
HTTP/1.1 200 OK
Date: Tue, 09 Nov 2010 14:49:00 GMT
Server: test-server/fake
Last-Modified: Tue, 13 Jun 2000 12:10:00 GMT
ETag: "21025-dc7-39462498"
Accept-Ranges: bytes
Content-Length: 6
Connection: something-close, close-something, close
Content-Type: text/html
Funny-head: yesyes
-foo-
</data>
</reply>
# Client-side
<client>
<features>
cookies
PSL
</features>
<server>
http
</server>
<name>
Load cookies from file where some are PSL domains
</name>
<command>
http://foo.co.uk:%HTTPPORT/%TESTNUMBER -b %LOGDIR/cookies.txt --resolve foo.co.uk:%HTTPPORT:%HOSTIP
</command>
<file name="%LOGDIR/cookies.txt">
.co.uk%TABTRUE%TAB/%TABFALSE%TAB0%TABbad%TABnot-allowed
foo.co.uk%TABTRUE%TAB/%TABFALSE%TAB0%TABgood%TABallowed
</file>
</client>
# Verify data after the test has been "shot"
<verify>
<protocol crlf="headers">
GET /%TESTNUMBER HTTP/1.1
Host: foo.co.uk:%HTTPPORT
User-Agent: curl/%VERSION
Accept: */*
Cookie: good=allowed
</protocol>
</verify>
</testcase>