fix(security): escape error text in OIDC error pages

OIDC error responses interpolated parse-error/exception and error_description
text straight into HTML. idpyoidc currently emits canned messages, but this is
the same reflected-XSS class as the validation-error fix; relying on upstream
not to echo input is fragile.

Add a shared _error_page() helper that HTML-escapes the message and route all
six dynamic error responses through it.

Refs: porchlight-8iw

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-04 11:06:08 +02:00
parent 71a7c23bdd
commit 7c4dbf2cd9
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
2 changed files with 26 additions and 6 deletions

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import html
import json import json
from types import SimpleNamespace from types import SimpleNamespace
from urllib.parse import urlencode from urllib.parse import urlencode
@ -14,6 +15,15 @@ from porchlight.oidc.claims import PorchlightUserInfo, user_to_claims
router = APIRouter(tags=["oidc"]) router = APIRouter(tags=["oidc"])
def _error_page(message: object, status_code: int = 400, title: str = "Error") -> HTMLResponse:
"""Render an error page, escaping the (possibly request-derived) message."""
return HTMLResponse(
f"<h1>{html.escape(title)}</h1><p>{html.escape(str(message))}</p>",
status_code=status_code,
)
SCOPE_DESCRIPTIONS: dict[str, str] = { SCOPE_DESCRIPTIONS: dict[str, str] = {
"openid": "Sign you in (required)", "openid": "Sign you in (required)",
"profile": "Your name and profile information", "profile": "Your name and profile information",
@ -59,11 +69,11 @@ async def authorization(request: Request) -> Response:
try: try:
parsed = endpoint.parse_request(query_params) parsed = endpoint.parse_request(query_params)
except Exception as exc: except Exception as exc:
return HTMLResponse(f"<h1>Invalid Request</h1><p>{exc}</p>", status_code=400) return _error_page(exc, title="Invalid Request")
if "error" in parsed: if "error" in parsed:
error_desc = parsed.get("error_description", parsed["error"]) error_desc = parsed.get("error_description", parsed["error"])
return HTMLResponse(f"<h1>Error</h1><p>{error_desc}</p>", status_code=400) return _error_page(error_desc)
# Check if user is authenticated # Check if user is authenticated
userid = request.session.get("userid") userid = request.session.get("userid")
@ -95,11 +105,11 @@ async def authorization_complete(request: Request) -> Response:
try: try:
parsed = endpoint.parse_request(auth_request_params) parsed = endpoint.parse_request(auth_request_params)
except Exception as exc: except Exception as exc:
return HTMLResponse(f"<h1>Error</h1><p>{exc}</p>", status_code=400) return _error_page(exc)
if "error" in parsed: if "error" in parsed:
error_desc = parsed.get("error_description", parsed["error"]) error_desc = parsed.get("error_description", parsed["error"])
return HTMLResponse(f"<h1>Error</h1><p>{error_desc}</p>", status_code=400) return _error_page(error_desc)
return await _check_consent_or_complete( return await _check_consent_or_complete(
request, oidc_server, endpoint, parsed, userid, username, auth_request_params request, oidc_server, endpoint, parsed, userid, username, auth_request_params
@ -172,7 +182,7 @@ async def _complete_authorization( # noqa: PLR0913
if "error" in result.get("response_args", {}): if "error" in result.get("response_args", {}):
response_args = result["response_args"] response_args = result["response_args"]
error_desc = response_args.get("error_description", response_args["error"]) error_desc = response_args.get("error_description", response_args["error"])
return HTMLResponse(f"<h1>Error</h1><p>{error_desc}</p>", status_code=400) return _error_page(error_desc)
# Build redirect URL # Build redirect URL
response_args = result.get("response_args", {}) response_args = result.get("response_args", {})
@ -361,6 +371,6 @@ async def consent_submit(request: Request) -> Response:
if "error" in parsed: if "error" in parsed:
raise ValueError(parsed.get("error_description", parsed["error"])) raise ValueError(parsed.get("error_description", parsed["error"]))
except Exception as exc: except Exception as exc:
return HTMLResponse(f"<h1>Error</h1><p>{exc}</p>", status_code=400) return _error_page(exc)
return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username) return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username)

View file

@ -2,6 +2,16 @@ import secrets
from httpx import AsyncClient from httpx import AsyncClient
from porchlight.oidc.endpoints import _error_page
def test_error_page_escapes_html() -> None:
# OIDC error pages must not interpolate request-derived text as raw HTML.
resp = _error_page("<script>alert(1)</script>")
body = resp.body.decode()
assert "<script>" not in body
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in body
def _register_test_client( def _register_test_client(
client: AsyncClient, client: AsyncClient,