Repository navigation
templated/CVE-2026-48020 #276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| # Traefik - StripPrefix Middleware Authentication Bypass (CVE-2026-48020) | ||
|
|
||
| ## Overview | ||
|
|
||
| Traefik versions prior to `2.11.48`, `3.6.19`, and `3.7.3` contain a path normalization flaw in the `StripPrefix` middleware. When a public router using `StripPrefix` shares a backend with protected routes (such as `/admin` or `/internal` guarded by authentication middleware), improper handling of relative path segments after prefix stripping can allow requests matched by the public router to resolve to protected backend paths without passing through the protected router's authentication middleware. | ||
|
|
||
| - **Vulnerability Type**: Authentication Bypass / Path Normalization Mismatch in `StripPrefix` Middleware | ||
| - **Severity**: Critical | ||
| - **Vulnerable Versions**: `< 2.11.48`, `3.0.0 - 3.6.18`, `3.7.0 - 3.7.2` (e.g., `v3.7.0`) | ||
| - **Patched Versions**: `2.11.48`, `3.6.19`, `3.7.3` or later | ||
|
|
||
| --- | ||
|
|
||
| ## Testbed Setup | ||
|
|
||
| The testbed uses Docker Compose to deploy two Traefik reverse proxy instances fronting a shared Python HTTP backend (`cve_backend` on port `9000`): | ||
| - **Vulnerable instance (`traefik_vulnerable_lab`)**: `traefik:v3.7.0` running on host port `18080`. | ||
| - **Patched / Safe instance (`traefik_patched_lab`)**: `traefik:v3.7.3` running on host port `18081`. | ||
| - **Backend service (`cve_backend`)**: Python `3.12-slim` application ([`backend.py`](./backend.py)) configured via [`dynamic.yml`](./dynamic.yml) with: | ||
| - A `public` router matching `PathPrefix(\`/api\`)` with the `strip-api` (`StripPrefix: /api`) middleware. | ||
| - A `protected` router matching `PathPrefix(\`/admin\`) || PathPrefix(\`/internal\`)` guarded by `BasicAuth` (`auth`) middleware. | ||
|
|
||
| ### Start the Testbed | ||
|
|
||
| ```bash | ||
| cd /usr/local/google/home/dyeddala/tsunami/security-testbeds/traefik/CVE-2026-48020 | ||
| docker compose up -d | ||
| ``` | ||
|
|
||
| ### Stop the Testbed | ||
|
|
||
| ```bash | ||
| docker compose down -v | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Verification | ||
|
|
||
| ### 1. Verify Endpoint Readiness | ||
|
|
||
| #### Step 1: Verify the Traefik instances and public `/api` route are active | ||
| ```bash | ||
| curl -i -s "http://127.0.0.1:18080/api" | head -n 15 | ||
| curl -i -s "http://127.0.0.1:18081/api" | head -n 15 | ||
| ``` | ||
| **Expected Response**: `HTTP/1.1 404 Not Found` with JSON body `{"status": "not_found", "seen_path": "/"}` from the upstream Python backend, confirming `/api` was stripped and forwarded to `backend:9000`. | ||
|
|
||
| #### Step 2: Verify direct access to `/admin` requires authentication on both instances | ||
| ```bash | ||
| curl -i -s "http://127.0.0.1:18080/admin" | head -n 15 | ||
| curl -i -s "http://127.0.0.1:18081/admin" | head -n 15 | ||
| ``` | ||
| **Expected Response**: `HTTP/1.1 401 Unauthorized` (with `Www-Authenticate: Basic realm="traefik"`), confirming the `protected` router's `BasicAuth` middleware blocks unauthenticated direct access. | ||
|
|
||
| --- | ||
|
|
||
| ### 2. Automated Side-by-Side Verification with Tsunami | ||
|
|
||
| ```bash | ||
| for port in 18080 18081; do | ||
| echo "=== Scanning Port $port ===" | ||
| docker run --rm --net=host \ | ||
| --entrypoint /usr/bin/tsunami \ | ||
| -v /tmp:/tmp \ | ||
| ghcr.io/google/tsunami-scanner-full \ | ||
| --ip-v4-target=127.0.0.1 \ | ||
| --detectors-include=Traefik_CVE_2026_48020 \ | ||
| --port-ranges-target="${port}" \ | ||
| --scan-results-local-output-format=JSON \ | ||
| --scan-results-local-output-filename="/tmp/tsunami-out-${port}.json" 2>&1 | grep "# of detected vulnerability:" | ||
| done | ||
| `` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,28 @@ | ||||||
| from http.server import BaseHTTPRequestHandler, HTTPServer | ||||||
| import json | ||||||
|
|
||||||
| class Handler(BaseHTTPRequestHandler): | ||||||
| def log_message(self, fmt, *args): | ||||||
| return | ||||||
|
|
||||||
| def _json(self, status, obj): | ||||||
| body = json.dumps(obj).encode() | ||||||
| self.send_response(status) | ||||||
| self.send_header("Content-Type", "application/json") | ||||||
| self.end_headers() | ||||||
| self.wfile.write(body) | ||||||
|
|
||||||
| def do_GET(self): | ||||||
| print(f"DEBUG: Request line: {self.requestline}, path: {self.path}") | ||||||
| if "/admin" in self.path or "/internal" in self.path: | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current path check uses
Suggested change
|
||||||
| self._json(200, { | ||||||
| "status": "success", | ||||||
| "seen_path": self.path, | ||||||
| "message": "Reached endpoint successfully" | ||||||
| }) | ||||||
| else: | ||||||
| self._json(404, {"status": "not_found", "seen_path": self.path}) | ||||||
|
|
||||||
| if __name__ == "__main__": | ||||||
| server = HTTPServer(("0.0.0.0", 9000), Handler) | ||||||
| server.serve_forever() | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| services: | ||
| traefik_vulnerable: | ||
| image: traefik:v3.7.0 | ||
| container_name: traefik_vulnerable_lab | ||
| command: | ||
| - "--providers.file.filename=/etc/traefik/dynamic.yml" | ||
| - "--entrypoints.web.address=:8080" | ||
| ports: | ||
| - "18080:8080" | ||
| volumes: | ||
| - ./dynamic.yml:/etc/traefik/dynamic.yml:ro | ||
| depends_on: | ||
| - backend | ||
|
|
||
| traefik_patched: | ||
| image: traefik:v3.7.3 | ||
| container_name: traefik_patched_lab | ||
| command: | ||
| - "--providers.file.filename=/etc/traefik/dynamic.yml" | ||
| - "--entrypoints.web.address=:8080" | ||
| ports: | ||
| - "18081:8080" | ||
| volumes: | ||
| - ./dynamic.yml:/etc/traefik/dynamic.yml:ro | ||
| depends_on: | ||
| - backend | ||
|
|
||
| backend: | ||
| image: python:3.12-slim | ||
| container_name: cve_backend | ||
| working_dir: /app | ||
| environment: | ||
| - PYTHONUNBUFFERED=1 | ||
| command: python /app/backend.py | ||
| volumes: | ||
| - ./backend.py:/app/backend.py:ro |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| http: | ||
| routers: | ||
| public: | ||
| rule: "PathPrefix(`/api`)" | ||
| entryPoints: | ||
| - web | ||
| middlewares: | ||
| - strip-api | ||
| service: backend | ||
|
|
||
| protected: | ||
| rule: "PathPrefix(`/admin`) || PathPrefix(`/internal`)" | ||
| entryPoints: | ||
| - web | ||
| middlewares: | ||
| - auth | ||
| service: backend | ||
|
|
||
| middlewares: | ||
| strip-api: | ||
| stripPrefix: | ||
| prefixes: | ||
| - /api | ||
| auth: | ||
| basicAuth: | ||
| users: | ||
| - "test:{PLAIN}test" | ||
|
|
||
| services: | ||
| backend: | ||
| loadBalancer: | ||
| servers: | ||
| - url: "http://backend:9000" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The path to the testbed directory is hardcoded to a specific user's local home directory (
/usr/local/google/home/dyeddala/...). This should be updated to a relative path so that the instructions work for any user.