From d0c8b3f419fda58dae507472ec9f0b8de32d4fd0 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 19 Oct 2026 09:56:58 -0500 Subject: [PATCH] Serve Hesychia public routes as real files with security headers. Unknown URLs were returning the app shell with status 200. Return 404 for missing paths, keep the extensionless pages, and send the security headers. Production short links redirect for HeyCatch. Co-authored-by: Cursor --- inventory/group_vars/all.yml | 13 ++++++++++ roles/web-static/templates/nginx.conf.j2 | 30 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/inventory/group_vars/all.yml b/inventory/group_vars/all.yml index 704e99a..3c75add 100644 --- a/inventory/group_vars/all.yml +++ b/inventory/group_vars/all.yml @@ -125,6 +125,19 @@ app_catalog: api_proxy_prefix: /api/finance/webhooks/ # /robots.txt, /sitemap.xml, and /llms.txt must not fall through to index.html. exact_root_files: true + # Homepage was missing these. Enforcing CSP stays loose so Tianji, the API, + # websockets, and inline styles keep working. Report-Only is the strict policy. + security_headers: true + # Unknown URLs are HTTP 404 with public/404.html, not 200 + the SPA shell. + # Slash routes are directory index.html files from scripts/stampCanonicals.js. + # These extensionless routes are the matching files (keep in sync with + # llm-fe/src/llm-fe/utils/canonicals.js paths that do not end in /). + error_page_404: /404.html + html_exact_paths: + - /legal/terms-of-service + - /billing/success + - /billing/cancel + - /document_storage chat_backend: type: django repo: "{{ git_base_url }}/ai_ml_operations/chat_backend.git" diff --git a/roles/web-static/templates/nginx.conf.j2 b/roles/web-static/templates/nginx.conf.j2 index 8c810b2..771bb53 100644 --- a/roles/web-static/templates/nginx.conf.j2 +++ b/roles/web-static/templates/nginx.conf.j2 @@ -17,6 +17,14 @@ log_format observability escape=json '"user_agent":"$http_user_agent"' '}'; +{% macro security_headers() %} + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self' https: wss: data: blob: 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always; + add_header Content-Security-Policy-Report-Only "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self'" always; +{% endmacro %} + {% for a in _static_apps %} server { listen {{ a.port }}; @@ -29,6 +37,9 @@ server { root {{ (app_catalog[a.name].webroot_pattern | default(web_static_root ~ '/{env}_' ~ a.name)) | replace('{env}', a.env) }}; index index.html; +{% if app_catalog[a.name].security_headers | default(false) %} +{{ security_headers() }} +{% endif %} {%- set api_backend_name = app_catalog[a.name].api_backend | default('') %} {%- if api_backend_name %} @@ -74,6 +85,22 @@ server { } {% endif %} +{% if app_catalog[a.name].html_exact_paths | default([]) %} +{% for p in app_catalog[a.name].html_exact_paths %} + location = {{ p }} { + default_type text/html; + try_files {{ p }} =404; + } +{% endfor %} +{% endif %} + +{% if a.name == 'chat_web_app' and a.env == 'prod' %} + # HeyCatch short links. Unknown paths 404, so the SDK never sees /x. + location ~ ^/([a-z0-9])$ { + return 302 /?utm_source=heycatch&utm_campaign=$1; + } +{% endif %} + {% if app_catalog[a.name].error_page_404 | default('') %} error_page 404 {{ app_catalog[a.name].error_page_404 }}; @@ -89,6 +116,9 @@ server { location ~* \.(?:js|css|woff2?|png|jpg|jpeg|gif|svg|ico)$ { expires 7d; add_header Cache-Control "public"; +{% if app_catalog[a.name].security_headers | default(false) %} +{{ security_headers() }} +{% endif %} } } {% endfor %}