GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-6HX8-3WJJ-GR8G

Vulnerability from github – Published: 2026-08-27 22:10 – Updated: 2026-08-27 22:10
VLAI
Summary
WebOb: Open redirect in Location header normalization via leading C0 control / space characters
Details

Summary

This is a third follow-up to CVE-2024-42353 / GHSA-mg3v-6m49-jhp3 and CVE-2026-44889 / GHSA-fh3h-vg37-cc95.

WebOb makes the Location header absolute when it serves a redirect. To stop a relative or protocol-relative target from redirecting users off-host, it checks the value for a URI scheme and for a leading //, then joins it against the request URI with urllib.parse.urljoin(). The previous fix additionally stripped ASCII tab/CR/LF from the value before those checks.

However, on Python 3.10+ urllib.parse.urljoin() (via urlsplit()) does more than remove tab/CR/LF: it also strips leading and trailing C0 control characters (U+0000U+001F) and spaces from the URL before parsing it. Because WebOb's guard checks (SCHEME_RE and startswith("//")) run against the un-stripped value, a single leading space or control byte slips past them, and urljoin() then silently removes that byte and parses what remains as a protocol-relative — or even absolute — URL. The result is an open redirect to an attacker-controlled host.

Details

Response._make_location_absolute() (in src/webob/response.py) performed, prior to the fix:

value = value.replace("\t", "").replace("\r", "").replace("\n", "")

if SCHEME_RE.search(value):          # ^[a-z]+:   -> already absolute, return as-is
    return value

if value.startswith("//"):           # neutralize protocol-relative URLs
    value = f"/%2f{value[2:]}"

new_location = urlparse.urljoin(_request_uri(environ), value)

Consider the Location value " //www.example.com/test" (a single leading space):

  1. The explicit strip only removes \t, \r, \n — the leading space survives.
  2. SCHEME_RE (^[a-z]+:) does not match — the value starts with a space.
  3. value.startswith("//") is False — the value starts with a space, not /. The ///%2f neutralization is skipped.
  4. urllib.parse.urljoin(_request_uri(environ), " //www.example.com/test") then strips the leading space before parsing, sees //www.example.com/test, treats it as protocol-relative, and returns http://www.example.com/test.

The same bypass works with a value such as " https://www.example.com/test" (leading space + a full scheme): SCHEME_RE does not match the space-prefixed string, but urljoin() strips the space and returns the fully absolute attacker URL https://www.example.com/test.

Any C0 control character works equally well in place of the space, e.g. "\x00//www.example.com/test" or "\x1f//www.example.com/test", because urlsplit() strips the whole leading C0-control-and-space run.

Affected entry points

  • Response.location — any application that sets a relative/attacker-influenced Location and serves the response (the classic redirect path).
  • Request.relative_url() — used urllib.parse.urljoin() directly and was subject to the same character stripping.
  • webob.exc._HTTPMove subclasses (HTTPMovedPermanently, HTTPFound, HTTPSeeOther, HTTPTemporaryRedirect, HTTPPermanentRedirect, etc.) — these built their absolute Location with urlparse.urljoin(req.path_url, self.location) without going through _make_location_absolute() at all, so they bypassed even the tab/CR/LF strip and the ///%2f neutralization. A protocol-relative location passed to e.g. HTTPFound(location="//evil.example") redirected off-host.

Proof of Concept

from webob import Response
from webob.request import Request

res = Response()
res.status = "301"
res.location = " //www.example.com/test"   # note the single leading space

req = Request.blank("/")                    # request host is "localhost"
print(req.get_response(res).location)
# Vulnerable (<= 1.8.10):  http://www.example.com/test   <-- open redirect
# Fixed:                   http://localhost/ //www.example.com/test

Absolute-URL variant:

res.location = " https://www.example.com/test"
# Vulnerable: https://www.example.com/test   <-- off-host
# Fixed:      http://localhost/ https://www.example.com/test

Via the HTTP exceptions:

from webob import exc

environ = {
    "wsgi.url_scheme": "http", "SERVER_NAME": "localhost",
    "SERVER_PORT": "80", "REQUEST_METHOD": "HEAD", "PATH_INFO": "/",
}
m = exc.HTTPFound(location="//www.example.com/test")
m(environ, lambda *a, **k: None)
print(m.location)
# Vulnerable: //www.example.com/test          <-- open redirect
# Fixed:      http://localhost/%2fwww.example.com/test

Impact

An unauthenticated remote attacker who controls (in whole or part) the redirect target of an application built on WebOb can redirect a user from a trusted host to an attacker-controlled host. This enables phishing and credential-theft campaigns that abuse the trusted origin, and can be chained with OAuth/SSO redirect_uri flows to leak tokens. Exploitation requires user interaction (following the redirect). Confidentiality and integrity impact are limited (L); the scope is changed (C) because the trust boundary of the originating site is crossed.

Patches

Fixed by replacing the use of urllib.parse.urljoin() with WebOb's own RFC 3986 reference-resolution implementation, webob.util.urljoin(), which resolves the reference exactly as given, character for character, with no whitespace or control-character removal.

  • Response._make_location_absolute() now uses webob.util.urljoin().
  • Request.relative_url() now uses webob.util.urljoin().
  • webob.exc._HTTPMove now normalizes its Location through the same _make_location_absolute() code path as Response, so protocol-relative and whitespace-smuggled locations are neutralized there too.

Users should upgrade to the patched release. There are no API changes.

Workarounds

  • Only ever set the Location header / redirect target to a fully-qualified URI whose host you control, or strictly allowlist redirect destinations before handing them to WebOb.
  • Reject any redirect target that does not begin with https://yourhost/ (or a validated relative path with no leading whitespace/control bytes).

References

  • This advisory: GHSA-6hx8-3wjj-gr8g
  • GHSA-fh3h-vg37-cc95 (CVE-2026-44889) — second incomplete fix (tab/CR/LF)
  • GHSA-mg3v-6m49-jhp3 (CVE-2024-42353) — original open redirect fix
  • RFC 3986, Section 5 — Reference Resolution: https://www.rfc-editor.org/rfc/rfc3986#section-5
  • Python urllib.parse URL stripping behavior (CPython 3.10+, removal of leading and trailing C0 control and space characters): https://docs.python.org/3/library/urllib.parse.html

To report a vulnerability to the Pylons Project please take a look at:

  • Pylons Project security policy and reporting process: https://github.com/Pylons/.github/blob/main/SECURITY.md
  • Security contact (private, coordinated disclosure): pylons-project-security@googlegroups.com (the Pylons Project requests a 90-day disclosure embargo)

Credit

Reported via the Pylons Project security mailing list by:

  • tonghuaroot — for the residual open redirect in Response._make_location_absolute(): the 1.8.10 fix stripped only ASCII tab/CR/LF, but urllib.parse.urljoin() also strips leading C0 control and space characters, so values such as " //attacker.example/path" (and " https://attacker.example/path") still escaped off-host.
  • Matheus Polkorny — for identifying that the webob.exc._HTTPMove redirect exceptions (HTTPFound and friends) performed their own urllib.parse.urljoin() normalization and never went through _make_location_absolute(), so a protocol-relative location such as //evil.example/path/ redirected off-host through that separate code path.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "webob"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.8.11"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54770"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-27T22:10:07Z",
    "nvd_published_at": "2026-08-20T17:18:18Z",
    "severity": "MODERATE"
  },
  "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`\u2013`U+001F`) and spaces from the URL before parsing it.**\nBecause WebOb\u0027s 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 \u2014 or even absolute \u2014 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` \u2014 the leading **space**\n   survives.\n2. `SCHEME_RE` (`^[a-z]+:`) does **not** match \u2014 the value starts with a space.\n3. `value.startswith(\"//\")` is **False** \u2014 the value starts with a space, not\n   `/`. The `//` \u2192 `/%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`** \u2014 any application that sets a relative/attacker-influenced\n  `Location` and serves the response (the classic redirect path).\n- **`Request.relative_url()`** \u2014 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.) \u2014 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 `//` \u2192 `/%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\u0027s 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) \u2014 second incomplete fix (tab/CR/LF)\n- GHSA-mg3v-6m49-jhp3 (CVE-2024-42353) \u2014 original open redirect fix\n- RFC 3986, Section 5 \u2014 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** \u2014 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** \u2014 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.",
  "id": "GHSA-6hx8-3wjj-gr8g",
  "modified": "2026-08-27T22:10:07Z",
  "published": "2026-08-27T22:10:07Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "WebOb: Open redirect in Location header normalization via leading C0 control / space characters"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…