Harden forward-auth redirect and header handling
All checks were successful
publish-latest / docker (push) Successful in 22s
All checks were successful
publish-latest / docker (push) Successful in 22s
Tighten forward-auth return URL validation to reject unsafe host syntax, including backslash-based browser/parser mismatches, and normalize scheme/port matching so bare allowlist entries are HTTPS default-port only. Support explicit scheme-qualified targets, including IPv6 host:port entries. Always emit deterministic identity headers with empty values when attributes are absent, zero unsafe header values, and clear stale forward-auth return targets when OIDC or registration flows take precedence. Add regression coverage for wildcard redirect bypasses, scheme/port handling, IPv6 allowlist entries, empty identity headers, unsafe header values, and stale return-target cleanup. Update docs with proxy header-stripping guidance and the new allowlist semantics.
This commit is contained in:
parent
58da15c825
commit
6b6ff29b9a
5 changed files with 325 additions and 60 deletions
|
|
@ -1,11 +1,14 @@
|
|||
import re
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from httpx import AsyncClient
|
||||
|
||||
from porchlight.authn.password import PasswordService
|
||||
from porchlight.authn.routes import _login_redirect_target
|
||||
from porchlight.forward_auth import FORWARD_AUTH_RETURN_TO_SESSION_KEY
|
||||
from porchlight.models import PasswordCredential, User
|
||||
|
||||
|
||||
|
|
@ -23,7 +26,7 @@ async def _create_user(
|
|||
username=username,
|
||||
preferred_username="Alice",
|
||||
email=email,
|
||||
groups=groups or ["users"],
|
||||
groups=groups if groups is not None else ["users"],
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
|
@ -108,6 +111,81 @@ async def test_forward_auth_ignores_disallowed_return_host(client: AsyncClient)
|
|||
assert res.headers["location"] == "http://localhost:8000/login"
|
||||
|
||||
|
||||
async def test_forward_auth_rejects_backslash_wildcard_return_host(client: AsyncClient) -> None:
|
||||
_allow_forward_auth_hosts(client, ["*.apps.example.com"])
|
||||
|
||||
res = await client.get(
|
||||
"/forward-auth",
|
||||
headers={
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": r"evil.com\x.apps.example.com",
|
||||
"X-Forwarded-Uri": "/private",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert res.status_code == 303
|
||||
assert res.headers["location"] == "http://localhost:8000/login"
|
||||
|
||||
|
||||
async def test_forward_auth_bare_hosts_require_https_default_port(client: AsyncClient) -> None:
|
||||
_allow_forward_auth_hosts(client, ["app.example.com"])
|
||||
|
||||
http_res = await client.get(
|
||||
"/forward-auth",
|
||||
headers={
|
||||
"X-Forwarded-Proto": "http",
|
||||
"X-Forwarded-Host": "app.example.com",
|
||||
"X-Forwarded-Uri": "/private",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
custom_port_res = await client.get(
|
||||
"/forward-auth",
|
||||
headers={
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "app.example.com:1337",
|
||||
"X-Forwarded-Uri": "/private",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert http_res.headers["location"] == "http://localhost:8000/login"
|
||||
assert custom_port_res.headers["location"] == "http://localhost:8000/login"
|
||||
|
||||
|
||||
async def test_forward_auth_normalizes_default_port_patterns(client: AsyncClient) -> None:
|
||||
_allow_forward_auth_hosts(client, ["app.example.com:443"])
|
||||
|
||||
res = await client.get(
|
||||
"/forward-auth",
|
||||
headers={
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "app.example.com",
|
||||
"X-Forwarded-Uri": "/private",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert parse_qs(urlsplit(res.headers["location"]).query) == {"return_to": ["https://app.example.com/private"]}
|
||||
|
||||
|
||||
async def test_forward_auth_supports_scheme_qualified_ipv6_patterns(client: AsyncClient) -> None:
|
||||
_allow_forward_auth_hosts(client, ["http://[::1]:8080"])
|
||||
|
||||
res = await client.get(
|
||||
"/forward-auth",
|
||||
headers={
|
||||
"X-Forwarded-Proto": "http",
|
||||
"X-Forwarded-Host": "[::1]:8080",
|
||||
"X-Forwarded-Uri": "/private",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert parse_qs(urlsplit(res.headers["location"]).query) == {"return_to": ["http://[::1]:8080/private"]}
|
||||
|
||||
|
||||
async def test_forward_auth_allows_non_safe_proxy_subrequests_without_csrf(client: AsyncClient) -> None:
|
||||
res = await client.post("/forward-auth", follow_redirects=False)
|
||||
|
||||
|
|
@ -153,6 +231,21 @@ async def test_login_ignores_disallowed_forward_auth_return_to(client: AsyncClie
|
|||
assert res.headers["HX-Redirect"] == "/manage/credentials"
|
||||
|
||||
|
||||
async def test_login_rejects_backslash_wildcard_return_to(client: AsyncClient) -> None:
|
||||
_allow_forward_auth_hosts(client, ["*.apps.example.com"])
|
||||
await _create_user(client)
|
||||
|
||||
token = await _login_page_csrf(client, "https://evil.com%5Cx.apps.example.com/")
|
||||
res = await client.post(
|
||||
"/login/password",
|
||||
data={"username": "alice", "password": "password123!Secure"},
|
||||
headers={"HX-Request": "true", "X-CSRF-Token": token},
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["HX-Redirect"] == "/manage/credentials"
|
||||
|
||||
|
||||
async def test_forward_auth_authenticated_user_returns_identity_headers(client: AsyncClient) -> None:
|
||||
await _create_user(client, groups=["users", "admin"], email="alice@example.com")
|
||||
await _login(client)
|
||||
|
|
@ -171,6 +264,31 @@ async def test_forward_auth_authenticated_user_returns_identity_headers(client:
|
|||
assert res.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
async def test_forward_auth_authenticated_user_returns_empty_identity_headers(client: AsyncClient) -> None:
|
||||
await _create_user(client, groups=[], email=None)
|
||||
await _login(client)
|
||||
|
||||
res = await client.get("/forward-auth", follow_redirects=False)
|
||||
|
||||
assert res.status_code == 204
|
||||
assert res.headers["remote-email"] == ""
|
||||
assert res.headers["remote-groups"] == ""
|
||||
assert res.headers["x-forwarded-email"] == ""
|
||||
assert res.headers["x-forwarded-groups"] == ""
|
||||
|
||||
|
||||
async def test_forward_auth_unsafe_header_values_are_zeroed(client: AsyncClient) -> None:
|
||||
user = await _create_user(client)
|
||||
app = client._transport.app # type: ignore[union-attr]
|
||||
await app.state.user_repo.update(user.model_copy(update={"preferred_username": "bad\x0bname"}))
|
||||
await _login(client)
|
||||
|
||||
res = await client.get("/forward-auth", follow_redirects=False)
|
||||
|
||||
assert res.status_code == 204
|
||||
assert res.headers["x-forwarded-preferred-username"] == ""
|
||||
|
||||
|
||||
async def test_forward_auth_rejects_inactive_session_user(client: AsyncClient) -> None:
|
||||
user = await _create_user(client)
|
||||
await _login(client)
|
||||
|
|
@ -182,3 +300,14 @@ async def test_forward_auth_rejects_inactive_session_user(client: AsyncClient) -
|
|||
|
||||
assert res.status_code == 303
|
||||
assert "remote-user" not in res.headers
|
||||
|
||||
|
||||
def test_login_redirect_target_clears_forward_auth_when_oidc_wins() -> None:
|
||||
request = MagicMock()
|
||||
request.session = {
|
||||
"oidc_auth_request": {"client_id": "test-rp"},
|
||||
FORWARD_AUTH_RETURN_TO_SESSION_KEY: "https://app.example.com/private",
|
||||
}
|
||||
|
||||
assert _login_redirect_target(request) == "/authorization/complete"
|
||||
assert FORWARD_AUTH_RETURN_TO_SESSION_KEY not in request.session
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue