Ignore malformed Host header when constructing request.url - #3279
Conversation
When the `Host` header contains characters invalid per RFC 9110 §7.2 (/, ?, #, @, \, spaces, etc.), `urlsplit` can produce a URL whose path differs from `scope["path"]`, making `request.url.path` unreliable. Validate the header against an allowlist regex (matching Werkzeug's and Django's approach) before using it. Fall back to the `server` tuple from scope when the header is malformed, same as when no `Host` header is present.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9403ad451
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| _CovariantValueType = TypeVar("_CovariantValueType", covariant=True) | ||
|
|
||
| # Rejects Host header chars (/, ?, #, @, ...) that would let urlsplit produce a path differing from scope["path"]. | ||
| _HOST_RE = re.compile(r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9.:]+\])(?::[0-9]+)?$", re.IGNORECASE) |
There was a problem hiding this comment.
Broaden Host validation to include URI-legal reg-name chars
The new _HOST_RE only allows [a-z0-9.-] (plus bracketed IPv6), but Host is defined as uri-host [":" port], and uri-host allows full reg-name syntax (including characters like _, ~, sub-delims, and percent-encoded octets). With this regex, syntactically valid Host values are now treated as malformed and URL(scope=...) silently falls back to scope['server'], so request.url.netloc can change from the actual request authority to the backend socket host/port in valid deployments.
Useful? React with 👍 / 👎.
Starlette 1.0.1 carries a Host-header parsing fix (Kludex/starlette#3279): when the `Host` header contains characters that are invalid per RFC 9110 §7.2 (`/`, `?`, `#`, `@`, `\`, spaces, ...), the URL string Starlette builds before calling `urlsplit` would push parts of `scope["path"]` into the netloc / query / fragment, leaving `request.url.path` disagreeing with the ASGI `scope["path"]` that downstream apps and `StaticFiles` actually serve. Airflow has two places that authorise off `request.url.path` and dispatch off `scope["path"]`: - `airflow-core/src/airflow/utils/serve_logs/log_server.py` — `JWTAuthStaticFiles.validate_jwt_token` compares `request.url.path` against the JWT's `filename` claim; the `StaticFiles` superclass then serves the file at `scope["path"]`. A malformed `Host` header makes those two disagree, letting a holder of any valid log-read token read any other task log on the same worker. - `providers/edge3/src/airflow/providers/edge3/worker_api/auth.py` — `jwt_token_authorization_rest` derives the called "method" from `request.url.path` while FastAPI routes by `scope["path"]`. Same shape of bypass on the Edge3 worker control plane. Bumping the floor to 1.0.1 closes both. A matching `[tool.uv.exclude-newer-package]` override is added so the security floor can be resolved before 1.0.1 ages past the project's global 4-day cooldown — the next commit teaches `upgrade_important_versions.py` to retire that override automatically once the cooldown catches up.
Starlette 1.0.1 carries a Host-header parsing fix (Kludex/starlette#3279): when the `Host` header contains characters that are invalid per RFC 9110 §7.2 (`/`, `?`, `#`, `@`, `\`, spaces, ...), the URL string Starlette builds before calling `urlsplit` would push parts of `scope["path"]` into the netloc / query / fragment, leaving `request.url.path` disagreeing with the ASGI `scope["path"]` that downstream apps and `StaticFiles` actually serve. Airflow has two places that authorise off `request.url.path` and dispatch off `scope["path"]`: - `airflow-core/src/airflow/utils/serve_logs/log_server.py` — `JWTAuthStaticFiles.validate_jwt_token` compares `request.url.path` against the JWT's `filename` claim; the `StaticFiles` superclass then serves the file at `scope["path"]`. A malformed `Host` header makes those two disagree, letting a holder of any valid log-read token read any other task log on the same worker. - `providers/edge3/src/airflow/providers/edge3/worker_api/auth.py` — `jwt_token_authorization_rest` derives the called "method" from `request.url.path` while FastAPI routes by `scope["path"]`. Same shape of bypass on the Edge3 worker control plane. Bumping the floor to 1.0.1 closes both. A matching `[tool.uv.exclude-newer-package]` override is added so the security floor can be resolved before 1.0.1 ages past the project's global 4-day cooldown — the next commit teaches `upgrade_important_versions.py` to retire that override automatically once the cooldown catches up.
* Require starlette>=1.0.1 to fix Host-header path divergence Starlette 1.0.1 carries a Host-header parsing fix (Kludex/starlette#3279): when the `Host` header contains characters that are invalid per RFC 9110 §7.2 (`/`, `?`, `#`, `@`, `\`, spaces, ...), the URL string Starlette builds before calling `urlsplit` would push parts of `scope["path"]` into the netloc / query / fragment, leaving `request.url.path` disagreeing with the ASGI `scope["path"]` that downstream apps and `StaticFiles` actually serve. Airflow has two places that authorise off `request.url.path` and dispatch off `scope["path"]`: - `airflow-core/src/airflow/utils/serve_logs/log_server.py` — `JWTAuthStaticFiles.validate_jwt_token` compares `request.url.path` against the JWT's `filename` claim; the `StaticFiles` superclass then serves the file at `scope["path"]`. A malformed `Host` header makes those two disagree, letting a holder of any valid log-read token read any other task log on the same worker. - `providers/edge3/src/airflow/providers/edge3/worker_api/auth.py` — `jwt_token_authorization_rest` derives the called "method" from `request.url.path` while FastAPI routes by `scope["path"]`. Same shape of bypass on the Edge3 worker control plane. Bumping the floor to 1.0.1 closes both. A matching `[tool.uv.exclude-newer-package]` override is added so the security floor can be resolved before 1.0.1 ages past the project's global 4-day cooldown — the next commit teaches `upgrade_important_versions.py` to retire that override automatically once the cooldown catches up. * Auto-honour and retire per-package exclude-newer overrides in upgrade script `upgrade_important_versions.py` enforced its own 4-day PyPI cooldown (`COOLDOWN_DAYS = 4`), which mirrored the root pyproject.toml's global `exclude-newer = "4 days"`. When a per-package override was added under `[tool.uv.exclude-newer-package]` (e.g. `uv = "12 hours"`) to let a freshly-published release through the global window, the script kept applying its broader cooldown and would pick a stale version that disagreed with what `uv lock` would resolve against pyproject.toml. This change makes the script: 1. Parse manual override blocks (the lines after the "# End of automatically generated …" sentinels under `[tool.uv.exclude-newer-package]` and `[tool.uv.pip.exclude-newer-package]`) and use any duration-shaped override as the per-package cooldown when checking PyPI. 2. Sweep up overrides whose target package is already older than the global 4-day window — the entry, plus its `# REMOVE BY …` markers, are removed from pyproject.toml so the workaround retires itself without anyone having to remember the calendar date in the comment. The "Manual overrides" header and broader context comments are left in place on purpose — the diff makes them obviously orphaned for a reviewer to prune in the same PR, but the script doesn't try to guess which surrounding lines belonged to which entry.
* Require starlette>=1.0.1 to fix Host-header path divergence Starlette 1.0.1 carries a Host-header parsing fix (Kludex/starlette#3279): when the `Host` header contains characters that are invalid per RFC 9110 §7.2 (`/`, `?`, `#`, `@`, `\`, spaces, ...), the URL string Starlette builds before calling `urlsplit` would push parts of `scope["path"]` into the netloc / query / fragment, leaving `request.url.path` disagreeing with the ASGI `scope["path"]` that downstream apps and `StaticFiles` actually serve. Airflow has two places that authorise off `request.url.path` and dispatch off `scope["path"]`: - `airflow-core/src/airflow/utils/serve_logs/log_server.py` — `JWTAuthStaticFiles.validate_jwt_token` compares `request.url.path` against the JWT's `filename` claim; the `StaticFiles` superclass then serves the file at `scope["path"]`. A malformed `Host` header makes those two disagree, letting a holder of any valid log-read token read any other task log on the same worker. - `providers/edge3/src/airflow/providers/edge3/worker_api/auth.py` — `jwt_token_authorization_rest` derives the called "method" from `request.url.path` while FastAPI routes by `scope["path"]`. Same shape of bypass on the Edge3 worker control plane. Bumping the floor to 1.0.1 closes both. A matching `[tool.uv.exclude-newer-package]` override is added so the security floor can be resolved before 1.0.1 ages past the project's global 4-day cooldown — the next commit teaches `upgrade_important_versions.py` to retire that override automatically once the cooldown catches up. * Auto-honour and retire per-package exclude-newer overrides in upgrade script `upgrade_important_versions.py` enforced its own 4-day PyPI cooldown (`COOLDOWN_DAYS = 4`), which mirrored the root pyproject.toml's global `exclude-newer = "4 days"`. When a per-package override was added under `[tool.uv.exclude-newer-package]` (e.g. `uv = "12 hours"`) to let a freshly-published release through the global window, the script kept applying its broader cooldown and would pick a stale version that disagreed with what `uv lock` would resolve against pyproject.toml. This change makes the script: 1. Parse manual override blocks (the lines after the "# End of automatically generated …" sentinels under `[tool.uv.exclude-newer-package]` and `[tool.uv.pip.exclude-newer-package]`) and use any duration-shaped override as the per-package cooldown when checking PyPI. 2. Sweep up overrides whose target package is already older than the global 4-day window — the entry, plus its `# REMOVE BY …` markers, are removed from pyproject.toml so the workaround retires itself without anyone having to remember the calendar date in the comment. The "Manual overrides" header and broader context comments are left in place on purpose — the diff makes them obviously orphaned for a reviewer to prune in the same PR, but the script doesn't try to guess which surrounding lines belonged to which entry. (cherry picked from commit 518eadf)
* Require starlette>=1.0.1 to fix Host-header path divergence Starlette 1.0.1 carries a Host-header parsing fix (Kludex/starlette#3279): when the `Host` header contains characters that are invalid per RFC 9110 §7.2 (`/`, `?`, `#`, `@`, `\`, spaces, ...), the URL string Starlette builds before calling `urlsplit` would push parts of `scope["path"]` into the netloc / query / fragment, leaving `request.url.path` disagreeing with the ASGI `scope["path"]` that downstream apps and `StaticFiles` actually serve. Airflow has two places that authorise off `request.url.path` and dispatch off `scope["path"]`: - `airflow-core/src/airflow/utils/serve_logs/log_server.py` — `JWTAuthStaticFiles.validate_jwt_token` compares `request.url.path` against the JWT's `filename` claim; the `StaticFiles` superclass then serves the file at `scope["path"]`. A malformed `Host` header makes those two disagree, letting a holder of any valid log-read token read any other task log on the same worker. - `providers/edge3/src/airflow/providers/edge3/worker_api/auth.py` — `jwt_token_authorization_rest` derives the called "method" from `request.url.path` while FastAPI routes by `scope["path"]`. Same shape of bypass on the Edge3 worker control plane. Bumping the floor to 1.0.1 closes both. A matching `[tool.uv.exclude-newer-package]` override is added so the security floor can be resolved before 1.0.1 ages past the project's global 4-day cooldown — the next commit teaches `upgrade_important_versions.py` to retire that override automatically once the cooldown catches up. * Auto-honour and retire per-package exclude-newer overrides in upgrade script `upgrade_important_versions.py` enforced its own 4-day PyPI cooldown (`COOLDOWN_DAYS = 4`), which mirrored the root pyproject.toml's global `exclude-newer = "4 days"`. When a per-package override was added under `[tool.uv.exclude-newer-package]` (e.g. `uv = "12 hours"`) to let a freshly-published release through the global window, the script kept applying its broader cooldown and would pick a stale version that disagreed with what `uv lock` would resolve against pyproject.toml. This change makes the script: 1. Parse manual override blocks (the lines after the "# End of automatically generated …" sentinels under `[tool.uv.exclude-newer-package]` and `[tool.uv.pip.exclude-newer-package]`) and use any duration-shaped override as the per-package cooldown when checking PyPI. 2. Sweep up overrides whose target package is already older than the global 4-day window — the entry, plus its `# REMOVE BY …` markers, are removed from pyproject.toml so the workaround retires itself without anyone having to remember the calendar date in the comment. The "Manual overrides" header and broader context comments are left in place on purpose — the diff makes them obviously orphaned for a reviewer to prune in the same PR, but the script doesn't try to guess which surrounding lines belonged to which entry. (cherry picked from commit 518eadf) Co-authored-by: Jarek Potiuk <jarek@potiuk.com>
* Require starlette>=1.0.1 to fix Host-header path divergence Starlette 1.0.1 carries a Host-header parsing fix (Kludex/starlette#3279): when the `Host` header contains characters that are invalid per RFC 9110 §7.2 (`/`, `?`, `#`, `@`, `\`, spaces, ...), the URL string Starlette builds before calling `urlsplit` would push parts of `scope["path"]` into the netloc / query / fragment, leaving `request.url.path` disagreeing with the ASGI `scope["path"]` that downstream apps and `StaticFiles` actually serve. Airflow has two places that authorise off `request.url.path` and dispatch off `scope["path"]`: - `airflow-core/src/airflow/utils/serve_logs/log_server.py` — `JWTAuthStaticFiles.validate_jwt_token` compares `request.url.path` against the JWT's `filename` claim; the `StaticFiles` superclass then serves the file at `scope["path"]`. A malformed `Host` header makes those two disagree, letting a holder of any valid log-read token read any other task log on the same worker. - `providers/edge3/src/airflow/providers/edge3/worker_api/auth.py` — `jwt_token_authorization_rest` derives the called "method" from `request.url.path` while FastAPI routes by `scope["path"]`. Same shape of bypass on the Edge3 worker control plane. Bumping the floor to 1.0.1 closes both. A matching `[tool.uv.exclude-newer-package]` override is added so the security floor can be resolved before 1.0.1 ages past the project's global 4-day cooldown — the next commit teaches `upgrade_important_versions.py` to retire that override automatically once the cooldown catches up. * Auto-honour and retire per-package exclude-newer overrides in upgrade script `upgrade_important_versions.py` enforced its own 4-day PyPI cooldown (`COOLDOWN_DAYS = 4`), which mirrored the root pyproject.toml's global `exclude-newer = "4 days"`. When a per-package override was added under `[tool.uv.exclude-newer-package]` (e.g. `uv = "12 hours"`) to let a freshly-published release through the global window, the script kept applying its broader cooldown and would pick a stale version that disagreed with what `uv lock` would resolve against pyproject.toml. This change makes the script: 1. Parse manual override blocks (the lines after the "# End of automatically generated …" sentinels under `[tool.uv.exclude-newer-package]` and `[tool.uv.pip.exclude-newer-package]`) and use any duration-shaped override as the per-package cooldown when checking PyPI. 2. Sweep up overrides whose target package is already older than the global 4-day window — the entry, plus its `# REMOVE BY …` markers, are removed from pyproject.toml so the workaround retires itself without anyone having to remember the calendar date in the comment. The "Manual overrides" header and broader context comments are left in place on purpose — the diff makes them obviously orphaned for a reviewer to prune in the same PR, but the script doesn't try to guess which surrounding lines belonged to which entry. (cherry picked from commit 518eadf) Co-authored-by: Jarek Potiuk <jarek@potiuk.com>
* Require starlette>=1.0.1 to fix Host-header path divergence Starlette 1.0.1 carries a Host-header parsing fix (Kludex/starlette#3279): when the `Host` header contains characters that are invalid per RFC 9110 §7.2 (`/`, `?`, `#`, `@`, `\`, spaces, ...), the URL string Starlette builds before calling `urlsplit` would push parts of `scope["path"]` into the netloc / query / fragment, leaving `request.url.path` disagreeing with the ASGI `scope["path"]` that downstream apps and `StaticFiles` actually serve. Airflow has two places that authorise off `request.url.path` and dispatch off `scope["path"]`: - `airflow-core/src/airflow/utils/serve_logs/log_server.py` — `JWTAuthStaticFiles.validate_jwt_token` compares `request.url.path` against the JWT's `filename` claim; the `StaticFiles` superclass then serves the file at `scope["path"]`. A malformed `Host` header makes those two disagree, letting a holder of any valid log-read token read any other task log on the same worker. - `providers/edge3/src/airflow/providers/edge3/worker_api/auth.py` — `jwt_token_authorization_rest` derives the called "method" from `request.url.path` while FastAPI routes by `scope["path"]`. Same shape of bypass on the Edge3 worker control plane. Bumping the floor to 1.0.1 closes both. A matching `[tool.uv.exclude-newer-package]` override is added so the security floor can be resolved before 1.0.1 ages past the project's global 4-day cooldown — the next commit teaches `upgrade_important_versions.py` to retire that override automatically once the cooldown catches up. * Auto-honour and retire per-package exclude-newer overrides in upgrade script `upgrade_important_versions.py` enforced its own 4-day PyPI cooldown (`COOLDOWN_DAYS = 4`), which mirrored the root pyproject.toml's global `exclude-newer = "4 days"`. When a per-package override was added under `[tool.uv.exclude-newer-package]` (e.g. `uv = "12 hours"`) to let a freshly-published release through the global window, the script kept applying its broader cooldown and would pick a stale version that disagreed with what `uv lock` would resolve against pyproject.toml. This change makes the script: 1. Parse manual override blocks (the lines after the "# End of automatically generated …" sentinels under `[tool.uv.exclude-newer-package]` and `[tool.uv.pip.exclude-newer-package]`) and use any duration-shaped override as the per-package cooldown when checking PyPI. 2. Sweep up overrides whose target package is already older than the global 4-day window — the entry, plus its `# REMOVE BY …` markers, are removed from pyproject.toml so the workaround retires itself without anyone having to remember the calendar date in the comment. The "Manual overrides" header and broader context comments are left in place on purpose — the diff makes them obviously orphaned for a reviewer to prune in the same PR, but the script doesn't try to guess which surrounding lines belonged to which entry. (cherry picked from commit 518eadf) Co-authored-by: Jarek Potiuk <jarek@potiuk.com>
Patch-level update fixing malformed Host header handling in request.url construction (Kludex/starlette#3279). Lockfile-only change; all CI checks passed.
|
@Kludex This PR seems AI hallucination and dangerous Host header can be UTF-8 percent encoded, or IDNA You seems rejecting valid host with such very strict ASCII-only regex Worse, weird host using (% or @ or anything now not included on the regex should be also good indicator for an attack, but your PR just mask totally the attack with fallback to server host. We lose critical information in such case. |
Your comment is dangerous. No AI hallucination here. I don't discuss vulnerabilities in public, but happy to reject your points with more context in a security advisory, as per our security policy. |
When the
Hostheader contains characters that are invalid per RFC 9110 §7.2 (/,?,#,@,\, spaces, ...),urlsplitcan produce aURLwhosepathdiffers fromscope["path"]. This makesrequest.url.pathunreliable for code that inspects it.This PR validates the
Hostheader against an allowlist regex (matching Werkzeug's and Django's approach) before using it - only[a-zA-Z0-9.-]for domains, valid IPv6 in brackets, and optional port numbers. If the header doesn't match, we fall back to theservertuple from scope, the same as when noHostheader is present.AI Disclaimer
This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.