Logical Flaw in curl_url_set Leads to Inconsistent Query Parameter Encoding
What the report claims
curl_url_set() exhibits inconsistent URL encoding behavior for CURLUPART_QUERY based on CURLU_APPENDQUERY flag rather than CURLU_URLENCODE, causing HTTP Parameter Pollution vulnerabilities.
Compare with curl's recorded outcome
Labeled AI slop
curl's maintainer lists this report as AI slop
Shown for comparison after analysis. The pipeline did not see this label.
ProveIt's finding
The code does what the report says, but curl's own policy may exclude it. A maintainer decides.
curl policy: API misuseSee the withdrawn model reasoning
The following reasoning failed the citation check and does not support the final finding.
- The divergent '=' encoding is intentional: CURLU_APPENDQUERY is documented as 'append a form style part' (include/curl/urlapi.h:95), meaning the input is a single key=value pair where the first '=' is a delimiter. The replace path treats the input as a raw string. This is a documented semantic difference, not a bug.
- The claimed HTTP Parameter Pollution (HPP) does not occur because all other special characters (:, /, &, etc.) are still percent-encoded in both modes (lib/urlapi.c:1866-1881). The attack scenario's example 'callback=http://attacker.com' would have ':' and '/' encoded to '%3A' and '%2F' even in append mode.
- The project's 'API misuse' policy applies: the report describes a behavior difference between two documented API modes, not a flaw that gives an attacker capabilities they did not already have. Applications choose the mode appropriate to their input format.
How this analysis ran 5 claims · 11 citations verified
Evidence check: the behavior is real; the project's policy section 'API misuse' may exclude it
Checked against curl master on 2025-10-29 (a97f9d41cc01). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.
Inspect the withdrawn draft reply
This draft belongs to the withdrawn judgment. It may contain unsupported claims.
Thank you for the detailed report and PoC. The code shows the different '=' handling is intentional: CURLU_APPENDQUERY means 'append a form-style key=value part' (see include/curl/urlapi.h:95), so the first '=' is a delimiter and is preserved; the replace path treats the input as a raw string. This semantic difference is documented by the flag's purpose. Crucially, all other special characters (:, /, &, etc.) are still percent-encoded in both modes (lib/urlapi.c:1866-1881), so the described HPP vector (injecting unencoded ':' or '/') does not occur. The PoC confirms the behavioral difference, but it is a documented API distinction, not a security flaw. We consider this 'API misuse' per our policy — the application must choose the mode matching its input format.
Inspect all claims and source code 5 claims · 2 code excerpts
This model judgment was withdrawn. The claim assessments below are preserved for inspection and may be wrong.
Report claims
The logic that determines whether to percent-encode the = character is incorrectly tied to the CURLU_APPENDQUERY flag, rather than being based purely on the CURLU_URLENCODE flag.
Read the reasoning
The code confirms that equalsencode is set to appendquery (line 1832), which is derived from CURLU_APPENDQUERY (line 1831), not from urlencode (CURLU_URLENCODE). This is the logical coupling the report describes.
Line ~1832 in lib/urlapi.c where equalsencode = appendquery;
Read the reasoning
The cited line exists and matches the report's description.
The inconsistency manifests when the same input string is processed with CURLU_URLENCODE flag during replace vs. append operations.
Read the reasoning
The code path shows that when CURLU_URLENCODE is set without CURLU_APPENDQUERY, equalsencode is FALSE and '=' is encoded; when both flags are set, equalsencode is TRUE and the first '=' is not encoded. The PoC output confirms this divergence.
This can lead to HTTP Parameter Pollution (HPP) vulnerabilities in applications that rely on libcurl.
Read the reasoning
The attack scenario claims unencoded ':', '/', '&' can be injected. However, the encoding loop (lines 1856-1882) percent-encodes every character that is not ISUNRESERVED, not allowed_in_path, and not the first '=' when equalsencode is TRUE. Colon, slash, and ampersand are not in the unreserved set and are therefore encoded in both replace and append modes. The PoC shows '&' becomes '%26' in both Test 2 and Test 3. The claimed HPP vector does not materialize.
The PoC demonstrates that the exact same input string ("a=b&c=d") is encoded differently depending on whether CURLU_APPENDQUERY is present.
Read the reasoning
The PoC demonstrates the exact same input 'a=b&c=d' produces 'a%3db%26c%3dd' (replace+encode) vs 'a=b%26c%3dd' (append+encode). The code confirms this is the current behavior.
What the report says about other projects
not addressed The logic for equalsencode in lib/urlapi.c (around line 1832) should not be tied to appendquery. The decision to encode = in a query string should be based only on whether CURLU_URLENCODE is set.
Read the original report (excerpt)
Source code
| 1774 | CURLUcode curl_url_set(CURLU *u, CURLUPart what, |
| 1775 | const char *part, unsigned int flags) |
| 1776 | { |
| 1777 | char **storep = NULL; |
| 1778 | bool urlencode = (flags & CURLU_URLENCODE) ? 1 : 0; |
| C1: urlencode is derived from CURLU_URLENCODE | |
| 1779 | bool plusencode = FALSE; |
| 1780 | bool pathmode = FALSE; |
| 1781 | bool leadingslash = FALSE; |
| 1782 | bool appendquery = FALSE; |
| 1783 | bool equalsencode = FALSE; |
| 1784 | size_t nalloc; |
| 1785 | |
| 1786 | if(!u) |
| 1787 | return CURLUE_BAD_HANDLE; |
| 1788 | if(!part) |
| 1789 | /* setting a part to NULL clears it */ |
| 1790 | return urlset_clear(u, what); |
| 1791 | |
| 1792 | nalloc = strlen(part); |
| 1793 | if(nalloc > CURL_MAX_INPUT_LENGTH) |
| 1794 | /* excessive input length */ |
| 1795 | return CURLUE_MALFORMED_INPUT; |
| 1796 | |
| 1797 | switch(what) { |
| 1798 | case CURLUPART_SCHEME: { |
| 1799 | CURLUcode status = set_url_scheme(u, part, flags); |
| 1800 | if(status) |
| 1801 | return status; |
| 1802 | storep = &u->scheme; |
| 1803 | urlencode = FALSE; /* never */ |
| 1804 | break; |
| 1805 | } |
| 1806 | case CURLUPART_USER: |
| 1807 | storep = &u->user; |
| 1808 | break; |
| 1809 | case CURLUPART_PASSWORD: |
| 1810 | storep = &u->password; |
| 1811 | break; |
| 1812 | case CURLUPART_OPTIONS: |
| 1813 | storep = &u->options; |
| 1814 | break; |
| 1815 | case CURLUPART_HOST: |
| 1816 | storep = &u->host; |
| 1817 | Curl_safefree(u->zoneid); |
| 1818 | break; |
| 1819 | case CURLUPART_ZONEID: |
| 1820 | storep = &u->zoneid; |
| 1821 | break; |
| 1822 | case CURLUPART_PORT: |
| 1823 | return set_url_port(u, part); |
| 1824 | case CURLUPART_PATH: |
| 1825 | pathmode = TRUE; |
| 1826 | leadingslash = TRUE; /* enforce */ |
| 1827 | storep = &u->path; |
| 1828 | break; |
| 1829 | case CURLUPART_QUERY: |
| 1830 | plusencode = urlencode; |
| 1831 | appendquery = (flags & CURLU_APPENDQUERY) ? 1 : 0; |
| C3: appendquery = (flags & CURLU_APPENDQUERY) ? 1 : 0; | |
| 1832 | equalsencode = appendquery; |
| C1: equalsencode = appendquery; ties the flag to CURLU_APPENDQUERY | |
| C2: equalsencode = appendquery; at line 1832 in the CURLUPART_QUERY case | |
| C3: equalsencode = appendquery; | |
| C5: equalsencode = appendquery; causes the divergence | |
| 1833 | storep = &u->query; |
| 1834 | u->query_present = TRUE; |
| 1835 | break; |
| 1836 | case CURLUPART_FRAGMENT: |
| 1837 | storep = &u->fragment; |
| 1838 | u->fragment_present = TRUE; |
| 1839 | break; |
| 1840 | case CURLUPART_URL: |
| 1841 | return set_url(u, part, nalloc, flags); |
| 1842 | default: |
| 1843 | return CURLUE_UNKNOWN_PART; |
| 1844 | } |
| 1845 | DEBUGASSERT(storep); |
| 1846 | { |
| 1847 | const char *newp; |
| 1848 | struct dynbuf enc; |
| 1849 | curlx_dyn_init(&enc, nalloc * 3 + 1 + leadingslash); |
| 1850 | |
| 1851 | if(leadingslash && (part[0] != '/')) { |
| 1852 | CURLcode result = curlx_dyn_addn(&enc, "/", 1); |
| 1853 | if(result) |
| 1854 | return cc2cu(result); |
| 1855 | } |
| 1856 | if(urlencode) { |
| 1857 | const unsigned char *i; |
| 1858 | |
| 1859 | for(i = (const unsigned char *)part; *i; i++) { |
| 1860 | CURLcode result; |
| 1861 | if((*i == ' ') && plusencode) { |
| 1862 | result = curlx_dyn_addn(&enc, "+", 1); |
| 1863 | if(result) |
| 1864 | return CURLUE_OUT_OF_MEMORY; |
| 1865 | } |
| 1866 | else if(ISUNRESERVED(*i) || |
| C4: ISUNRESERVED(*i) and allowed_in_path(*i) guard other characters | |
| 1867 | (pathmode && allowed_in_path(*i)) || |
| 1868 | ((*i == '=') && equalsencode)) { |
| C1: ((*i == '=') && equalsencode) uses equalsencode to skip encoding '=' | |
| C3: ((*i == '=') && equalsencode) controls '=' encoding | |
| C5: First '=' skipped only when equalsencode is TRUE | |
| 1869 | if((*i == '=') && equalsencode) |
| 1870 | /* only skip the first equals sign */ |
| 1871 | equalsencode = FALSE; |
| 1872 | result = curlx_dyn_addn(&enc, i, 1); |
| 1873 | if(result) |
| 1874 | return cc2cu(result); |
| 1875 | } |
| 1876 | else { |
| 1877 | unsigned char out[3]={'%'}; |
| C4: Non-unreserved characters (:, /, &, etc.) are percent-encoded via curlx_dyn_addn | |
| 1878 | Curl_hexbyte(&out[1], *i); |
| 1879 | result = curlx_dyn_addn(&enc, out, 3); |
| 1880 | if(result) |
| 1881 | return cc2cu(result); |
| 1882 | } |
| 1883 | } |
| 1884 | } |
| 1885 | else { |
| 1886 | char *p; |
| 1887 | CURLcode result = curlx_dyn_add(&enc, part); |
| 1888 | if(result) |
| 1889 | return cc2cu(result); |
| 1890 | p = curlx_dyn_ptr(&enc); |
| 1891 | while(*p) { |
| 1892 | /* make sure percent encoded are lower case */ |
| 1893 | if((*p == '%') && ISXDIGIT(p[1]) && ISXDIGIT(p[2]) && |
| 1894 | (ISUPPER(p[1]) || ISUPPER(p[2]))) { |
| 1895 | p[1] = Curl_raw_tolower(p[1]); |
| 1896 | p[2] = Curl_raw_tolower(p[2]); |
| 1897 | p += 3; |
| 1898 | } |
| 1899 | else |
| 1900 | p++; |
| 1901 | } |
| 1902 | } |
| 1903 | newp = curlx_dyn_ptr(&enc); |
| 1904 | |
| 1905 | if(appendquery && newp) { |
| 1906 | /* Append the 'newp' string onto the old query. Add a '&' separator if |
| 1907 | none is present at the end of the existing query already */ |
| 1908 | |
| 1909 | size_t querylen = u->query ? strlen(u->query) : 0; |
| 1910 | bool addamperand = querylen && (u->query[querylen -1] != '&'); |
| 1911 | if(querylen) { |
| 1912 | struct dynbuf qbuf; |
| 1913 | curlx_dyn_init(&qbuf, CURL_MAX_INPUT_LENGTH); |
| 1914 | |
| 1915 | if(curlx_dyn_addn(&qbuf, u->query, querylen)) /* add original query */ |
| 1916 | goto nomem; |
| 1917 | |
| 1918 | if(addamperand) { |
| 1919 | if(curlx_dyn_addn(&qbuf, "&", 1)) |
| 1920 | goto nomem; |
| 1921 | } |
| 1922 | if(curlx_dyn_add(&qbuf, newp)) |
| 1923 | goto nomem; |
| 1924 | curlx_dyn_free(&enc); |
| 1925 | free(*storep); |
| 1926 | *storep = curlx_dyn_ptr(&qbuf); |
| 1927 | return CURLUE_OK; |
| 1928 | nomem: |
| 1929 | curlx_dyn_free(&enc); |
| 1930 | return CURLUE_OUT_OF_MEMORY; |
| 1931 | } |
| 1932 | } |
| 1933 | |
| 1934 | else if(what == CURLUPART_HOST) { |
| 94 | #define CURLU_URLENCODE (1<<7) /* URL encode on set */ |
| 95 | #define CURLU_APPENDQUERY (1<<8) /* append a form style part */ |