GHSA-6X26-6R6F-M537
Vulnerability from github – Published: 2026-07-31 18:36 – Updated: 2026-07-31 18:36Summary
The ALLOWED_SOURCES configuration is meant to restrict which hosts Thumbor's HTTP loader may fetch images from. Plain-string entries in that list (the overwhelming majority of real-world and documented configurations) are passed directly to re.match() without escaping. Because . is a regex wildcard, every dot in a domain name becomes a bypass vector: s.glbimg.com silently matches sXglbimgYcom, sAglbimg.com, and any other hostname that differs only at a dot position. This undermines the primary SSRF defence that ALLOWED_SOURCES is intended to provide.
Affected component
thumbor/loaders/http_loader.py — validate()
Proof of concept
import re
from thumbor.config import Config
from thumbor.context import Context
from thumbor.loaders import http_loader as loader
config = Config()
config.ALLOWED_SOURCES = ["s.glbimg.com"] # typical user config
ctx = Context(None, config, None)
# These should be blocked — both return True due to the unescaped dot
print(loader.validate(ctx, "http://sXglbimgYcom/secret.jpg")) # True ← bypass
print(loader.validate(ctx, "http://sAglbimg.com/secret.jpg")) # True ← bypass
# Legitimate origin — correctly allowed
print(loader.validate(ctx, "http://s.glbimg.com/logo.jpg")) # True ← correct
Root cause
thumbor/loaders/http_loader.py (before fix):
for pattern in context.config.ALLOWED_SOURCES:
if isinstance(pattern, Pattern):
match = url
else:
pattern = f"^{pattern}$" # <-- dots not escaped, act as regex wildcard
match = res.hostname
if re.match(pattern, match):
return True
Impact
An attacker who can influence the image source URL passed to Thumbor can fetch images from arbitrary hosts, bypassing the ALLOWED_SOURCES allowlist.
Preconditions:
ALLOWED_SOURCEScontains at least one plain-string entry (the common case; all official documentation examples use plain strings).- The attacker can supply or influence the image URL — true whenever
ALLOW_UNSAFE_URL = True(the default), or when the application forwards user input to a signed URL endpoint.
Fix
Apply re.escape() to plain-string patterns before compiling them, so every
character is matched literally:
else:
pattern = f"^{re.escape(pattern)}$" # dots and other metacharacters are now literal
match = res.hostname
This is a one-call addition with no breaking change for correctly written configurations. Users who need real regular-expression behaviour should supply a compiled pattern (re.compile(r"s\.glbimg\.com")), which is already handled by the existing isinstance(pattern, Pattern) branch and is unaffected by this change.
The ALLOWED_SOURCES docstring in config.py was also updated to document the two-mode behaviour explicitly.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 7.7.7"
},
"package": {
"ecosystem": "PyPI",
"name": "thumbor"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "7.8.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-53500"
],
"database_specific": {
"cwe_ids": [
"CWE-1333"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-31T18:36:03Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nThe `ALLOWED_SOURCES` configuration is meant to restrict which hosts Thumbor\u0027s HTTP loader may fetch images from. Plain-string entries in that list (the overwhelming majority of real-world and documented configurations) are passed directly to `re.match()` without escaping. Because `.` is a regex wildcard, every dot in a domain name becomes a bypass vector: `s.glbimg.com` silently matches `sXglbimgYcom`, `sAglbimg.com`, and any other hostname that differs only at a dot position. This undermines the primary SSRF defence that `ALLOWED_SOURCES` is intended to provide.\n\n## Affected component\n\n`thumbor/loaders/http_loader.py` \u2014 `validate()`\n\n## Proof of concept\n\n```python\nimport re\nfrom thumbor.config import Config\nfrom thumbor.context import Context\nfrom thumbor.loaders import http_loader as loader\n\nconfig = Config()\nconfig.ALLOWED_SOURCES = [\"s.glbimg.com\"] # typical user config\nctx = Context(None, config, None)\n\n# These should be blocked \u2014 both return True due to the unescaped dot\nprint(loader.validate(ctx, \"http://sXglbimgYcom/secret.jpg\")) # True \u2190 bypass\nprint(loader.validate(ctx, \"http://sAglbimg.com/secret.jpg\")) # True \u2190 bypass\n\n# Legitimate origin \u2014 correctly allowed\nprint(loader.validate(ctx, \"http://s.glbimg.com/logo.jpg\")) # True \u2190 correct\n```\n\n## Root cause\n\n`thumbor/loaders/http_loader.py` (before fix):\n\n```python\nfor pattern in context.config.ALLOWED_SOURCES:\n if isinstance(pattern, Pattern):\n match = url\n else:\n pattern = f\"^{pattern}$\" # \u003c-- dots not escaped, act as regex wildcard\n match = res.hostname\n\n if re.match(pattern, match):\n return True\n```\n\n## Impact\n\nAn attacker who can influence the image source URL passed to Thumbor can fetch images from arbitrary hosts, bypassing the `ALLOWED_SOURCES` allowlist.\n\n**Preconditions:**\n\n- `ALLOWED_SOURCES` contains at least one plain-string entry (the common case; all official documentation examples use plain strings).\n- The attacker can supply or influence the image URL \u2014 true whenever `ALLOW_UNSAFE_URL = True` (the default), or when the application forwards user input to a signed URL endpoint.\n\n## Fix\n\nApply `re.escape()` to plain-string patterns before compiling them, so every\ncharacter is matched literally:\n\n```python\nelse:\n pattern = f\"^{re.escape(pattern)}$\" # dots and other metacharacters are now literal\n match = res.hostname\n```\n\nThis is a one-call addition with no breaking change for correctly written configurations. Users who need real regular-expression behaviour should supply a compiled pattern (`re.compile(r\"s\\.glbimg\\.com\")`), which is already handled by the existing `isinstance(pattern, Pattern)` branch and is unaffected by this change.\n\nThe `ALLOWED_SOURCES` docstring in `config.py` was also updated to document the two-mode behaviour explicitly.",
"id": "GHSA-6x26-6r6f-m537",
"modified": "2026-07-31T18:36:03Z",
"published": "2026-07-31T18:36:03Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/thumbor/thumbor/security/advisories/GHSA-6x26-6r6f-m537"
},
{
"type": "WEB",
"url": "https://github.com/thumbor/thumbor/commit/68876715350c6c8f49c324e5515e64908830aed7"
},
{
"type": "PACKAGE",
"url": "https://github.com/thumbor/thumbor"
},
{
"type": "WEB",
"url": "https://github.com/thumbor/thumbor/releases/tag/7.8.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "Thumbor treats ALLOWED_SOURCES string patterns as unescaped regex, allowing hostname bypass via wildcard dot"
}
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.