Skip to content

Ignore malformed Host header when constructing request.url - #3279

Merged
Kludex merged 1 commit into
mainfrom
ignore-malformed-host-header
May 21, 2026
Merged

Kludex merged 1 commit into
mainfrom
ignore-malformed-host-header

Conversation

@Kludex

@Kludex Kludex commented May 21, 2026

Copy link
Copy Markdown
Owner

When the Host header contains characters that are invalid per RFC 9110 §7.2 (/, ?, #, @, \, spaces, ...), urlsplit can produce a URL whose path differs from scope["path"]. This makes request.url.path unreliable for code that inspects it.

This PR validates the Host header 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 the server tuple from scope, the same as when no Host header is present.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

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.
@Kludex
Kludex merged commit 764dab0 into main May 21, 2026
11 checks passed
@Kludex
Kludex deleted the ignore-malformed-host-header branch May 21, 2026 16:49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Kludex Kludex mentioned this pull request May 21, 2026
vatsrahul1001 pushed a commit to potiuk/airflow that referenced this pull request May 25, 2026
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.
vatsrahul1001 pushed a commit to potiuk/airflow that referenced this pull request May 25, 2026
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.
potiuk added a commit to apache/airflow that referenced this pull request May 25, 2026
* 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.
vatsrahul1001 pushed a commit to apache/airflow that referenced this pull request May 25, 2026
* 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)
vatsrahul1001 added a commit to apache/airflow that referenced this pull request May 25, 2026
* 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>
vatsrahul1001 added a commit to apache/airflow that referenced this pull request May 25, 2026
* 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>
vatsrahul1001 added a commit to apache/airflow that referenced this pull request May 25, 2026
* 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>
helebest pushed a commit to OpenDIKW/dikw-core that referenced this pull request Jun 7, 2026
Patch-level update fixing malformed Host header handling in request.url construction (Kludex/starlette#3279). Lockfile-only change; all CI checks passed.
@aeris

aeris commented Jun 8, 2026

Copy link
Copy Markdown

@Kludex This PR seems AI hallucination and dangerous

Host header can be UTF-8 percent encoded, or IDNA
https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.2

The reg-name syntax allows percent-encoded octets in order to
   represent non-ASCII registered names in a uniform way that is
   independent of the underlying name resolution technology.  Non-ASCII
   characters must first be encoded according to UTF-8 [[STD63](https://www.rfc-editor.org/info/rfc3986/#ref-STD63)], and then
   each octet of the corresponding UTF-8 sequence must be percent-
   encoded to be represented as URI characters.

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.

@Kludex

Kludex commented Jun 8, 2026

Copy link
Copy Markdown
Owner Author

@Kludex This PR seems AI hallucination and dangerous

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.

Repository owner locked as resolved and limited conversation to collaborators Jun 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants