dbb0850c7935dec65d3394ef1ddcdc10dafac5cf braney Tue Aug 18 10:03:38 2026 -0700 cheapcgi: parse %hh escapes directly instead of with sscanf, refs #37262 cgiDecode and cgiDecodeFull read each %hh escape with sscanf(in, "%2x", &code). glibc builds a stream over the whole remaining string on every sscanf call, so each call scans to the terminating null. That makes the cost of a decode quadratic in the length of one variable's value. The cost is real on data we already have. A saved session in hgcentraltest holds a single 699 KB hgFind.matches value with 58,930 escapes; decoding it takes 0.29 s of CPU, and cart.c loadHash does it on every load of that session. The database cart has no size cap, so this is not bounded by the 1 MB limit on request input that went in for #37452. At that 1 MB limit a single request still costs over 2 s. Reading the two hex digits directly is a few hundred times faster (430x on the 699 KB value) and never walks past them. Behavior is unchanged for well-formed input: verified byte-identical over the top 200 carts of namedSessionDb, sessionDb and userDb (2.4 million values, 14.7 MB), over an exhaustive sweep of every "%" plus two arbitrary bytes, and over cgiEncode/cgiDecode round trips of all 256 byte values. Decoding now differs only where a "%" is not followed by two hex digits, which nothing legitimate produces - none of the 659,624 escapes in those carts are malformed. The old code was worse there anyway: sscanf skips leading whitespace, so "% 0Z" decoded to a null byte in the middle of the value and silently truncated it. Malformed escapes now yield '?' like other bad input. Also removes the FAST_CGI_DECODE ifdef added earlier on this ticket. It never touched cgiDecode, so it does not describe anything now that the real cost is fixed. Its per-variable caps are superseded by the total input cap from #37452, which aborts with a message rather than dropping a variable silently, and cgiParseNext's variant silently skipped oversized variables for the ENCODE/CIRM tag tools that are its only callers. diff --git src/lib/cheapcgi.c src/lib/cheapcgi.c index 2decd97f4de..8adfa653083 100644 --- src/lib/cheapcgi.c +++ src/lib/cheapcgi.c @@ -6,33 +6,30 @@ #include "common.h" #include "hash.h" #include "cheapcgi.h" #include "portable.h" #include "linefile.h" #include "errAbort.h" #include "filePath.h" #include "htmshell.h" #include "dystring.h" #ifndef GBROWSE #include "mime.h" #endif /* GBROWSE */ #include -// FAST_CGI_DECODE can be defined in cheapcgi.h to try a faster decode process that -// also limits variable/value lengths and applies encoding to variable names - //============ javascript inline-separation routines =============== // One of the main services that CSP (Content Security Policy) provides // is protecting from reflected and stored XSS attacks by disabling all inline javacript, // both in script tags, and in inline event handlers. The separated javascript // can be either added back to the end of the html page with a nonce or sha hashid, // or it can be saved to a temp file in trash and then included as a non-inline, off-page .js. struct dyString *jsInlineLines = NULL; void jsInlineInit() /* init if needed */ { if (!jsInlineLines) { @@ -814,49 +811,35 @@ namePt = str; while (isNotEmpty(namePt)) { dataPt = strchr(namePt, '='); if (dataPt == NULL) errAbort("Mangled Cookie input string: no = in '%s' (offset %d in complete cookie string: '%s')", namePt, (int)(namePt - str), getenv("HTTP_COOKIE")); *dataPt++ = 0; nextNamePt = strchr(dataPt, ';'); if (nextNamePt != NULL) { *nextNamePt++ = 0; if (*nextNamePt == ' ') nextNamePt++; } -#ifndef FAST_CGI_DECODE cgiDecode(dataPt,dataPt,strlen(dataPt)); AllocVar(el); el->val = dataPt; slAddHead(&list, el); hashAddSaveName(hash, namePt, el, &el->name); -#else - int dataSize = strlen(dataPt); - int nameSize = strlen(namePt); - if ((dataSize <= CGI_VAR_SIZE_LIMIT) && (nameSize < CGI_VAR_NAME_LIMIT)) - { - cgiDecode(namePt,namePt,nameSize); - cgiDecode(dataPt,dataPt,dataSize); - AllocVar(el); - el->val = dataPt; - slAddHead(&list, el); - hashAddSaveName(hash, namePt, el, &el->name); - } -#endif // FAST_CGI_DECODE namePt = nextNamePt; } haveCookiesHash = TRUE; slReverse(&list); *retList = list; *retHash = hash; } char *findCookieData(char *varName) /* Get the string associated with varName from the cookie string. */ { struct hashEl *hel; char *firstResult; @@ -955,93 +938,53 @@ next = el->next; cgiDictionaryFree(&el); } *pList = NULL; } boolean cgiParseNext(char **pInput, char **retVar, char **retVal) /* Parse out next var/val in a var=val&var=val... cgi formatted string * This will insert zeroes and other things into string. * Usage: * char *pt = cgiStringStart; * char *var, *val * while (cgiParseNext(&pt, &var, &val)) * printf("%s\t%s\n", var, val); */ { -#ifndef FAST_CGI_DECODE char *var = *pInput; if (var == NULL || var[0] == 0) return FALSE; char *val = strchr(var, '='); if (val == NULL) errAbort("Mangled CGI input string %s", var); *val++ = 0; char *end = strchr(val, '&'); if (end == NULL) end = strchr(val, ';'); // For DAS if (end == NULL) { end = val + strlen(val); *pInput = NULL; } else { *pInput = end+1; *end = 0; } *retVar = var; *retVal = val; cgiDecode(val,val,end-val); -#else -char *val = NULL; -char *var = NULL; -int varLength = 0; -int valLength = 0; -do - { - var = *pInput; - if (var == NULL || var[0] == 0) - { - *retVar = *retVal = NULL; - return FALSE; - } - val = strchr(var, '='); - if (val == NULL || var == val) - errAbort("Mangled CGI input string %s", var); - varLength = val-var; - *val++ = 0; - char *end = strchr(val, '&'); - if (end == NULL) - end = strchr(val, ';'); // For DAS - if (end == NULL) - { - end = val + strlen(val); - *pInput = NULL; - } - else - { - *pInput = end+1; - *end = 0; - } - *retVar = var; - *retVal = val; - valLength = end-val; - } while ((varLength > CGI_VAR_NAME_LIMIT) || (valLength > CGI_VAR_SIZE_LIMIT)); - // skip variables that are too big -cgiDecode(var,var,varLength); -cgiDecode(val,val,valLength); -#endif // FAST_CGI_DECODE return TRUE; } void cgiSetMaxLogLen(int l) /* set the size of variable values that are dumped to stderr. Default is 0, which means no logging */ { logCgiVarMaxLen = l; } void cgiParseInputAbort(char *input, struct hash **retHash, struct cgiVar **retList) /* Parse cgi-style input into a hash table and list. This will alter * the input data. The hash table will contain references back * into input, so please don't free input until you're done with * the hash. Prints message aborts if there's an error. @@ -1065,48 +1008,36 @@ dataPt = strchr(namePt, '='); if (dataPt == NULL) { errAbort("Mangled CGI input string %s", namePt); } *dataPt++ = 0; nextNamePt = strchr(dataPt, '&'); if (nextNamePt == NULL) nextNamePt = strchr(dataPt, ';'); /* Accomodate DAS. */ if (nextNamePt != NULL) *nextNamePt++ = 0; if (logMsg && dataPt && strlen(dataPt) < logCgiVarMaxLen) dyStringPrintf(logMsg, "%s=%s ", namePt, dataPt); // if dataPt is empty string, still print it, could be important -#ifndef FAST_CGI_DECODE - cgiDecode(namePt,namePt,strlen(namePt)); /* for unusual ct names */ - cgiDecode(dataPt,dataPt,strlen(dataPt)); - AllocVar(el); - el->val = dataPt; - slAddHead(&list, el); - hashAddSaveName(hash, namePt, el, &el->name); -#else - if ((strlen(namePt) < CGI_VAR_NAME_LIMIT) && (strlen(dataPt) < CGI_VAR_SIZE_LIMIT)) - { cgiDecode(namePt,namePt,strlen(namePt)); /* for unusual ct names */ cgiDecode(dataPt,dataPt,strlen(dataPt)); AllocVar(el); el->val = dataPt; slAddHead(&list, el); hashAddSaveName(hash, namePt, el, &el->name); - } -#endif // FAST_CGI_DECODE namePt = nextNamePt; } if (logMsg) { char *logStr = dyStringCannibalize(&logMsg); fprintf(stderr, "CGIVARS %s\n", logStr); freez(&logStr); } slReverse(&list); *retList = list; *retHash = hash; @@ -1404,76 +1335,108 @@ * It should not be used in the rest of the URL. * So in the query string part of a URL, do use cgiEncode/cgiDecode. * And in the rest of the URL, use cgiEncodeFUll/cgiDecodeFull * which do not code space as plus. * Since FTP does not use URLs with query parameters, use the Full version. */ /* SECURITY (refs #38051): 0x01 is the in-band marker that sqlSafef (jksql.c) and * htmlSafef (htmshell.c) use to delimit the values they must escape. A request * value carrying this byte can forge a delimiter pair and smuggle unescaped text * into a query or into page output, so drop it here as it is decoded. It is * never legitimate in a request. Only 0x01 - tab, newline and CR are left alone, * since those are legitimate in custom-track textarea uploads. */ #define CGI_ESCAPE_MARKER 0x01 +static int cgiHexDigit(char c) +/* Return the value 0-15 of one hexadecimal digit, or -1 if c is not one. */ +{ +if (c >= '0' && c <= '9') + return c - '0'; +if (c >= 'a' && c <= 'f') + return c - 'a' + 10; +if (c >= 'A' && c <= 'F') + return c - 'A' + 10; +return -1; +} + +static int cgiEscapedByte(char *in, int avail) +/* Decode the two hex digits of a %hh escape, where in points just past the '%' + * and avail is the number of input bytes remaining there. Return '?' if the + * escape is malformed, matching what the old sscanf-based code did for input + * with no leading hex digit at all. + * + * PERFORMANCE (refs #37262): this used to be sscanf(in, "%2x", &code). glibc + * builds a stream over the whole remaining string on each sscanf call, so it + * scans to the terminating null every time. That made decoding cost time + * quadratic in the length of the value, and a value is as long as a cart + * variable: 0.3s of CPU on every load of a session we already have on disk, + * over 2s for a request at the input size limit. Reading the two digits + * directly is a few hundred times faster and never walks past them. */ +{ +if (avail >= 2) + { + int hi = cgiHexDigit(in[0]); + int lo = cgiHexDigit(in[1]); + if (hi >= 0 && lo >= 0) + return (hi << 4) + lo; + } +return '?'; +} + void cgiDecode(char *in, char *out, int inLength) /* Decode from cgi pluses-for-spaces format to normal. * Out will be a little shorter than in typically, and * can be the same buffer. */ { char c; int i; for (i=0; i