"""App-owned response security headers plus an enforced ``Cache-Control``. This module owns only the headers an application can correctly assert for itself: the static hardening headers (nosniff, frame DENY, referrer policy, cross-origin opener policy, and a locked-down content security policy) and an authoritative ``Cache-Control``. The CSP is the API-appropriate ``default-src 'none'; frame-ancestors 'none'``: a JSON API renders nothing, so if a response is ever sniffed or framed in a browser, nothing loads or executes. HSTS and stripping the ``Server`` banner are the edge's job (the app cannot know the public scheme or terminate TLS), so they are intentionally absent. The static headers use ``setdefault`` so a view or host app may override them, but ``Cache-Control`` is resolved three ways: mutating methods (POST/PUT/PATCH/DELETE) and ``@no_store``-marked views are hard-forced to ``no-store`` (a caching directive on such a response is a misuse — force-corrected and logged, never raised); a response that set no ``Cache-Control`` gets ``no-cache``; and a non-sensitive response's own ``Cache-Control`` is honored, which is how reference endpoints assert ``public, max-age`` for downstream shared caches. Passthrough responses (``send_file`` and other ``direct_passthrough`` bodies) are returned untouched so their byte stream is never consumed; ordinary streamed (``stream_with_context``) responses still receive the headers, which is safe because setting headers does not read the body iterator. """ from collections.abc import Callable from typing import TypeVar from flask import Flask, Response, request from core.config import Config from core.hardening.view_markers import resolve_view_marker _NO_STORE_ATTR = '_no_store' _MUTATING = frozenset({'POST', 'PUT', 'PATCH', 'DELETE'}) F = TypeVar('F', bound=Callable[..., object]) def no_store(view: F) -> F: """Mark a view so its responses are ``Cache-Control: no-store``. Returns it unchanged.""" setattr(view, _NO_STORE_ATTR, True) return view def setup_security_headers(app: Flask, config: type[Config]) -> None: """Install the app-owned security headers on ``app`` (always on, no enable flag). Every non-passthrough response gains the four static hardening headers (each via ``setdefault`` so a view may override) and an authoritative ``Cache-Control`` resolved three ways: mutating methods and ``@no_store``-marked views are hard-forced to ``no-store`` (a caching directive on such a response is a misuse -- force-corrected and logged, never raised); a response that set no ``Cache-Control`` gets ``no-cache``; and a non-sensitive response's own ``Cache-Control`` is honored (how reference endpoints assert ``public, max-age``). """ @app.after_request def _headers(resp: Response) -> Response: if resp.direct_passthrough: return resp resp.headers.setdefault('X-Content-Type-Options', 'nosniff') resp.headers.setdefault('X-Frame-Options', 'DENY') resp.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin') resp.headers.setdefault('Cross-Origin-Opener-Policy', 'same-origin') resp.headers.setdefault( 'Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'" ) # resolve_view_marker resolves @no_store on class-based MethodView routes (the # dominant style: ItemView/ListView), where the marker lands on the get()/post() # method rather than the as_view() closure in view_functions, and mirrors Flask's # HEAD->get fallback so a HEAD is classified like the GET it actually runs. sensitive = request.method in _MUTATING or bool( resolve_view_marker( app, request.endpoint, request.method, _NO_STORE_ATTR, False ) ) if sensitive: # Invariant: a mutation / @no_store response must never be stored. # Hard overwrite regardless of what the view set. If the view set a # caching directive, that is a misuse: force-correct and warn, but do # not raise — this hook runs after the write has committed, so raising # would misreport a mutation that already succeeded. if 'Cache-Control' in resp.headers: app.logger.warning( 'Cache-Control %r set on sensitive response (%s %s); forcing no-store', resp.headers.get('Cache-Control'), request.method, request.endpoint, ) resp.headers['Cache-Control'] = 'no-store' elif 'Cache-Control' not in resp.headers: resp.headers['Cache-Control'] = 'no-cache' # else: honor the view's own Cache-Control (e.g. cache_reference_data's # public, max-age). This is the durable reference-caching path. return resp