fix(routes): log and cleanly 500 on unreadable HTML page (#4637)

* fix(routes): serve 404 instead of 500 when an HTML page file is missing

_serve_html_with_nonce opened the HTML file with no error handling, and
callers such as /backgrounds and /login pass their paths in with no
existence check, so a missing or unreadable file raised an unhandled
OSError that surfaced as a 500. Wrap the read and raise HTTPException(404)
instead; the normal render path (CSP-nonce substitution) is unchanged.

Fixes #4594

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(routes): distinguish missing page (404) from read failure (500)

The previous fix caught a broad OSError and returned 404 for every
failure, which masks real server-side problems (permission errors, I/O
failures) as "not found" and lets them slip past error alerting. Split
FileNotFoundError (genuine 404) from other OSError, which now logs the
exception and returns a generic 500 — without leaking the OS error
string or file path into the response body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(routes): treat unreadable bundled HTML page as logged 500, not 404

Per PR #4637 review: every caller of the page-render helper serves a fixed,
server-owned template (index/login/backgrounds), never a client-supplied
path. So a missing or unreadable file is a server fault (broken deployment),
not a client "not found" — a 404 there mislabels a server error and hides a
missing core template from 5xx alerting, contradicting the OSError->500
rationale this PR is built on. Collapse both branches into a single logged,
leak-free 500.

Move the helper to src.app_helpers.serve_html_with_nonce so the behavior can
be unit-tested without importing the whole app (app.py is the slim
orchestrator; the test harness stubs src.database, so importing app in tests
is not viable). Add tests pinning missing/unreadable -> 500 (not 404) and
nonce injection on the happy path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ahmed Dlshad
2026-06-23 17:12:32 +03:00
committed by GitHub
parent 30dd789351
commit 8f5e36a079
3 changed files with 88 additions and 15 deletions
+6 -14
View File
@@ -44,7 +44,7 @@ from typing import Dict
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
from fastapi.responses import JSONResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
@@ -65,7 +65,7 @@ from core.exceptions import (
import bcrypt as _bcrypt
from src.app_helpers import abs_join
from src.app_helpers import abs_join, serve_html_with_nonce
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
from starlette.responses import RedirectResponse
@@ -791,22 +791,14 @@ app.include_router(setup_companion_routes())
# ========= ROUTES (kept in app.py) =========
def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
"""Read an HTML file and inject the CSP nonce into inline <script> tags."""
with open(file_path, "r", encoding="utf-8") as f:
html = f.read()
nonce = getattr(request.state, "csp_nonce", "")
html = html.replace("{{CSP_NONCE}}", nonce)
return HTMLResponse(html)
@app.get("/")
async def serve_index(request: Request):
static_path = abs_join(BASE_DIR, "static/index.html")
if os.path.exists(static_path):
return _serve_html_with_nonce(request, static_path)
return serve_html_with_nonce(request, static_path)
root_path = abs_join(BASE_DIR, "index.html")
if os.path.exists(root_path):
return _serve_html_with_nonce(request, root_path)
return serve_html_with_nonce(request, root_path)
raise HTTPException(404, "index.html not found")
@app.get("/notes")
@@ -848,13 +840,13 @@ async def serve_library(request: Request):
@app.get("/backgrounds")
async def serve_backgrounds(request: Request):
"""Sandbox page for prototyping background effects. No auth required."""
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
@app.get("/login")
async def serve_login(request: Request):
if not AUTH_ENABLED:
return RedirectResponse(url="/", status_code=302)
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
@app.get("/api/version")
async def get_version():
+30 -1
View File
@@ -1,6 +1,13 @@
# src/app_helpers.py
import os
import base64
import logging
import os
from fastapi import HTTPException
from fastapi.responses import HTMLResponse
from starlette.requests import Request
logger = logging.getLogger(__name__)
def read_if_exists(path: str) -> str:
"""Read file if it exists, return empty string otherwise."""
@@ -20,6 +27,28 @@ def abs_join(base_dir: str, rel: str) -> str:
"""Join paths and return absolute path."""
return os.path.abspath(os.path.join(base_dir, rel))
def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
"""Read an app-bundled HTML page and inject the CSP nonce into inline <script> tags.
Callers pass fixed, server-owned template paths (index/login/backgrounds),
never a client-supplied path. So any read failure here — a missing file
(broken deployment) or a permission/IO error — is a server fault, not a
client "not found": map all of them to a logged 500 so a missing core
template surfaces in 5xx alerting instead of hiding behind a 404. If a
future caller serves a client-influenced path where 404 is correct, branch
that at the call site rather than defaulting this shared helper to 404.
"""
try:
with open(file_path, "r", encoding="utf-8") as f:
html = f.read()
except OSError:
logger.exception("Failed to read page %s", file_path)
raise HTTPException(500, "Internal server error")
nonce = getattr(request.state, "csp_nonce", "")
html = html.replace("{{CSP_NONCE}}", nonce)
return HTMLResponse(html)
def inside_base_dir(base_dir: str, path: str) -> bool:
"""Check if path is inside base directory."""
if not isinstance(base_dir, str) or not isinstance(path, str):
+52
View File
@@ -0,0 +1,52 @@
"""Behavior tests for src.app_helpers.serve_html_with_nonce.
Every caller of this helper serves a fixed, app-bundled template
(index/login/backgrounds), never a client-supplied path. So a read failure —
a missing file (broken deployment) or a permission/IO error — is a server
fault, not a client "not found", and must surface as a logged 500 rather than
hiding behind a 404 where 5xx alerting can't see it. These tests lock that
intent (raised in the PR #4637 review).
"""
import types
import pytest
pytest.importorskip("fastapi")
pytest.importorskip("starlette.responses")
from fastapi import HTTPException
from src.app_helpers import serve_html_with_nonce
def _request_with_nonce(nonce: str = ""):
"""Minimal stand-in for a Starlette Request: only request.state.csp_nonce is read."""
return types.SimpleNamespace(state=types.SimpleNamespace(csp_nonce=nonce))
def test_missing_fixed_template_returns_500_not_404(tmp_path):
missing = tmp_path / "does_not_exist.html"
with pytest.raises(HTTPException) as exc_info:
serve_html_with_nonce(_request_with_nonce(), str(missing))
assert exc_info.value.status_code == 500
# Generic detail — no OS error string or absolute path leaked to the client.
assert exc_info.value.detail == "Internal server error"
def test_unreadable_template_returns_500(tmp_path):
# A directory at the path makes open() raise an OSError subtype
# (IsADirectoryError on POSIX, PermissionError on Windows) — same branch.
a_dir = tmp_path / "a_dir.html"
a_dir.mkdir()
with pytest.raises(HTTPException) as exc_info:
serve_html_with_nonce(_request_with_nonce(), str(a_dir))
assert exc_info.value.status_code == 500
def test_readable_template_injects_nonce(tmp_path):
page = tmp_path / "page.html"
page.write_text('<script nonce="{{CSP_NONCE}}">x</script>', encoding="utf-8")
resp = serve_html_with_nonce(_request_with_nonce("nonce-abc"), str(page))
assert resp.status_code == 200
body = resp.body.decode("utf-8")
assert "nonce-abc" in body
assert "{{CSP_NONCE}}" not in body