{"id":"PYSEC-2026-3943","summary":"WebOb: Open redirect in Location header normalization via leading C0 control / space characters","details":"## Summary\n\nThis is a third follow-up to **CVE-2024-42353 / GHSA-mg3v-6m49-jhp3**\nand **CVE-2026-44889 / GHSA-fh3h-vg37-cc95**.\n\nWebOb makes the `Location` header absolute when it serves a redirect. To stop a\nrelative or protocol-relative target from redirecting users off-host, it checks\nthe value for a URI scheme and for a leading `//`, then joins it against the\nrequest URI with `urllib.parse.urljoin()`. The previous fix additionally stripped\nASCII tab/CR/LF from the value before those checks.\n\nHowever, on Python 3.10+ `urllib.parse.urljoin()` (via `urlsplit()`) does more\nthan remove tab/CR/LF: **it also strips leading and trailing C0 control\ncharacters (`U+0000`–`U+001F`) and spaces from the URL before parsing it.**\nBecause WebOb's guard checks (`SCHEME_RE` and `startswith(\"//\")`) run against the\n*un-stripped* value, a single leading space or control byte slips past them, and\n`urljoin()` then silently removes that byte and parses what remains as a\nprotocol-relative — or even absolute — URL. The result is an open redirect to an\nattacker-controlled host.\n\n## Details\n\n`Response._make_location_absolute()` (in `src/webob/response.py`) performed,\nprior to the fix:\n\n```python\nvalue = value.replace(\"\\t\", \"\").replace(\"\\r\", \"\").replace(\"\\n\", \"\")\n\nif SCHEME_RE.search(value):          # ^[a-z]+:   -\u003e already absolute, return as-is\n    return value\n\nif value.startswith(\"//\"):           # neutralize protocol-relative URLs\n    value = f\"/%2f{value[2:]}\"\n\nnew_location = urlparse.urljoin(_request_uri(environ), value)\n```\n\nConsider the Location value `\" //www.example.com/test\"` (a single leading space):\n\n1. The explicit strip only removes `\\t`, `\\r`, `\\n` — the leading **space**\n   survives.\n2. `SCHEME_RE` (`^[a-z]+:`) does **not** match — the value starts with a space.\n3. `value.startswith(\"//\")` is **False** — the value starts with a space, not\n   `/`. The `//` → `/%2f` neutralization is skipped.\n4. `urllib.parse.urljoin(_request_uri(environ), \" //www.example.com/test\")` then\n   **strips the leading space** before parsing, sees `//www.example.com/test`,\n   treats it as protocol-relative, and returns\n   `http://www.example.com/test`.\n\nThe same bypass works with a value such as `\" https://www.example.com/test\"`\n(leading space + a full scheme): `SCHEME_RE` does not match the space-prefixed\nstring, but `urljoin()` strips the space and returns the fully absolute\nattacker URL `https://www.example.com/test`.\n\nAny C0 control character works equally well in place of the space, e.g.\n`\"\\x00//www.example.com/test\"` or `\"\\x1f//www.example.com/test\"`, because\n`urlsplit()` strips the whole leading C0-control-and-space run.\n\n### Affected entry points\n\n- **`Response.location`** — any application that sets a relative/attacker-influenced\n  `Location` and serves the response (the classic redirect path).\n- **`Request.relative_url()`** — used `urllib.parse.urljoin()` directly and was\n  subject to the same character stripping.\n- **`webob.exc._HTTPMove` subclasses** (`HTTPMovedPermanently`, `HTTPFound`,\n  `HTTPSeeOther`, `HTTPTemporaryRedirect`, `HTTPPermanentRedirect`, etc.) — these\n  built their absolute Location with `urlparse.urljoin(req.path_url, self.location)`\n  **without** going through `_make_location_absolute()` at all, so they bypassed\n  even the tab/CR/LF strip and the `//` → `/%2f` neutralization. A protocol-relative\n  location passed to e.g. `HTTPFound(location=\"//evil.example\")` redirected off-host.\n\n## Proof of Concept\n\n```python\nfrom webob import Response\nfrom webob.request import Request\n\nres = Response()\nres.status = \"301\"\nres.location = \" //www.example.com/test\"   # note the single leading space\n\nreq = Request.blank(\"/\")                    # request host is \"localhost\"\nprint(req.get_response(res).location)\n# Vulnerable (\u003c= 1.8.10):  http://www.example.com/test   \u003c-- open redirect\n# Fixed:                   http://localhost/ //www.example.com/test\n```\n\nAbsolute-URL variant:\n\n```python\nres.location = \" https://www.example.com/test\"\n# Vulnerable: https://www.example.com/test   \u003c-- off-host\n# Fixed:      http://localhost/ https://www.example.com/test\n```\n\nVia the HTTP exceptions:\n\n```python\nfrom webob import exc\n\nenviron = {\n    \"wsgi.url_scheme\": \"http\", \"SERVER_NAME\": \"localhost\",\n    \"SERVER_PORT\": \"80\", \"REQUEST_METHOD\": \"HEAD\", \"PATH_INFO\": \"/\",\n}\nm = exc.HTTPFound(location=\"//www.example.com/test\")\nm(environ, lambda *a, **k: None)\nprint(m.location)\n# Vulnerable: //www.example.com/test          \u003c-- open redirect\n# Fixed:      http://localhost/%2fwww.example.com/test\n```\n\n## Impact\n\nAn unauthenticated remote attacker who controls (in whole or part) the redirect\ntarget of an application built on WebOb can redirect a user from a trusted host to\nan attacker-controlled host. This enables phishing and credential-theft campaigns\nthat abuse the trusted origin, and can be chained with OAuth/SSO `redirect_uri`\nflows to leak tokens. Exploitation requires user interaction (following the\nredirect). Confidentiality and integrity impact are limited (`L`); the scope is\nchanged (`C`) because the trust boundary of the originating site is crossed.\n\n## Patches\n\nFixed by replacing the use of `urllib.parse.urljoin()` with WebOb's own\nRFC 3986 reference-resolution implementation, `webob.util.urljoin()`, which\nresolves the reference **exactly as given, character for character, with no\nwhitespace or control-character removal**.\n\n- `Response._make_location_absolute()` now uses `webob.util.urljoin()`.\n- `Request.relative_url()` now uses `webob.util.urljoin()`.\n- `webob.exc._HTTPMove` now normalizes its Location through the same\n  `_make_location_absolute()` code path as `Response`, so protocol-relative and\n  whitespace-smuggled locations are neutralized there too.\n\nUsers should upgrade to the patched release. There are no API changes.\n\n## Workarounds\n\n- Only ever set the `Location` header / redirect target to a fully-qualified URI\n  whose host you control, or strictly allowlist redirect destinations before\n  handing them to WebOb.\n- Reject any redirect target that does not begin with `https://yourhost/` (or a\n  validated relative path with no leading whitespace/control bytes).\n\n## References\n\n- This advisory: GHSA-6hx8-3wjj-gr8g\n- GHSA-fh3h-vg37-cc95 (CVE-2026-44889) — second incomplete fix (tab/CR/LF)\n- GHSA-mg3v-6m49-jhp3 (CVE-2024-42353) — original open redirect fix\n- RFC 3986, Section 5 — Reference Resolution: https://www.rfc-editor.org/rfc/rfc3986#section-5\n- Python `urllib.parse` URL stripping behavior (CPython 3.10+, removal of leading\n  and trailing C0 control and space characters): https://docs.python.org/3/library/urllib.parse.html\n  \nTo report a vulnerability to the Pylons Project please take a look at:\n\n - Pylons Project security policy and reporting process:\n  https://github.com/Pylons/.github/blob/main/SECURITY.md\n- Security contact (private, coordinated disclosure): `pylons-project-security@googlegroups.com`\n  (the Pylons Project requests a 90-day disclosure embargo)\n\n## Credit\n\nReported via the Pylons Project security mailing list by:\n\n- **tonghuaroot** — for the residual open redirect in\n  `Response._make_location_absolute()`: the 1.8.10 fix stripped only ASCII\n  tab/CR/LF, but `urllib.parse.urljoin()` also strips leading C0 control and\n  space characters, so values such as `\" //attacker.example/path\"` (and\n  `\" https://attacker.example/path\"`) still escaped off-host.\n- **Matheus Polkorny** — for identifying that the `webob.exc._HTTPMove`\n  redirect exceptions (`HTTPFound` and friends) performed their own\n  `urllib.parse.urljoin()` normalization and never went through\n  `_make_location_absolute()`, so a protocol-relative location such as\n  `//evil.example/path/` redirected off-host through that separate code path.","aliases":["CVE-2026-54770","GHSA-6hx8-3wjj-gr8g"],"modified":"2026-09-10T12:15:13.320787314Z","published":"2026-09-10T09:44:57.798668Z","references":[{"type":"WEB","url":"https://github.com/Pylons/webob/security/advisories/GHSA-6hx8-3wjj-gr8g"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54770"},{"type":"WEB","url":"https://github.com/Pylons/webob/commit/ff89560643fb252751b4db8806a283b5377f1f07"},{"type":"PACKAGE","url":"https://github.com/Pylons/webob"},{"type":"WEB","url":"https://github.com/Pylons/webob/tree/1.8.11"},{"type":"PACKAGE","url":"https://pypi.org/project/webob"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-6hx8-3wjj-gr8g"}],"affected":[{"package":{"name":"webob","ecosystem":"PyPI","purl":"pkg:pypi/webob"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.8.11"}]}],"versions":["0.8","0.8.1","0.8.2","0.8.3","0.8.4","0.8.5","0.9","0.9.1","0.9.2","0.9.3","0.9.4","0.9.5","0.9.6","0.9.6.1","0.9.7","0.9.7.1","0.9.8","1.0","1.0.1","1.0.2","1.0.3","1.0.4","1.0.5","1.0.6","1.0.7","1.0.8","1.1","1.1.1","1.1b2","1.1beta1","1.1rc1","1.2","1.2.1","1.2.2","1.2.3","1.2b1","1.2b2","1.2b3","1.2rc1","1.3","1.3.1","1.4","1.4.1","1.4.2","1.5.0","1.5.0a0","1.5.0a1","1.5.0b0","1.5.1","1.6.0","1.6.0a0","1.6.1","1.6.2","1.6.3","1.6.4","1.7.0","1.7.0rc1","1.7.0rc2","1.7.1","1.7.2","1.7.3","1.7.4","1.8.0","1.8.0rc1","1.8.1","1.8.10","1.8.2","1.8.3","1.8.4","1.8.5","1.8.6","1.8.7","1.8.8","1.8.9"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/webob/PYSEC-2026-3943.yaml"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"}]}