Security-stack contract check — secure-by-default as a contract (#182).
This category is the canonical owner of the mutating route definition
referenced by the forms/auth epics. Other rules that need "what counts as a
mutating route" importMUTATING_METHODS / is_mutating_routefrom here.
A route is mutating when either is true:
- It accepts a mutating HTTP method (POST/PUT/PATCH/DELETE), or
- It is a filesystem page that ships
_actions.pyform actions (route.actionsis non-empty). This is Chirp's canonical form-action pattern: thepage.pymay declare onlyget(), yet the page mutates state via POST-to-self dispatched on the_actionform field. Such a page is method-GET in the router but is unmistakably a mutating surface, so it must clear the same CSRF/Session bar as a POST route.
Category:
security_stack: a mutating route exists but the security stack is not fully wired.
Severity matrix (env-aware, mirroringrules_safety/rules_deploy):
- Missing
CSRFMiddlewareorSessionMiddlewareon an app with any mutating route →ERRORin production,WARNINGin staging, and silent in development (the default) so dev apps and shipped examples stay clean. - Missing
SecurityHeadersMiddleware→WARNINGunconditionally (whenever any mutating route exists), independent of env. This is the agreed decision: CSRF/Session and SecurityHeaders are two distinct severity tracks.
No middleware is force-injected intoApp(). The lever is this contract plus
scaffold defaults (#183), per the explicit-over-magic convention.
Middleware presence is detected by class name (type(mw).__name__), not
isinstance. This matches the established pattern in
rules_safety.check_csrf_session_orderand
rules_csrf_forms._csrf_middleware_active: it avoids importing middleware
classes into the contracts layer (keeping the dependency direction clean). The
trade-off is that a user subclass is only recognised when it keeps the same
class name.
contracts.rules_security_stack
| Name | Type | Default | Description |
|---|---|---|---|
type
|
|
— | |
qualified_name
|
|
— | |
element_type
|
|
— | |
description
|
|
— | |
source_file
|
|
— | |
line_number
|
|
— | |
is_autodoc
|
|
— | |
autodoc_element
|
|
— | |
_autodoc_template
|
|
— | |
_autodoc_url_path
|
|
— | |
_autodoc_page_type
|
|
— | |
title
|
|
— | |
doc_content_hash
|
|
— |
Symbols on this page
Return True whenrouteis a mutating surface.
Canonical predicate for the "mutating route" definition. A route is mutating when either:
- it accepts…
Flag mutating routes that are not protected by the security stack.
A mutating route needs CSRF + session protection. A route is mutating when…
is_mutating_route
function
def is_mutating_route(route: Any) -> bool
Return True whenrouteis a mutating surface.
Canonical predicate for the "mutating route" definition. A route is mutating when either:
- it accepts a mutating HTTP method —
route.methodsintersectsMUTATING_METHODS; or - it is a filesystem page carrying
_actions.pyform actions —route.actionsis non-empty. These pages mutate state via POST-to-self on the_actionform field even whenpage.pydeclares onlyget(), so they must be treated as mutating.
RuntimeRoute objects expose methods only; discovered PageRoute
objects expose bothmethods and actions. getattrdefaults keep
the predicate total for either shape.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
route
|
Any
|
— |
check_security_stack
function
def check_security_stack(router: Router, config: Any, middleware_list: list[Any], discovered_routes: list[Any] | None = None) -> list[ContractIssue]
Flag mutating routes that are not protected by the security stack.
A mutating route needs CSRF + session protection. A route is mutating when
it accepts a mutating HTTP method (POST/PUT/PATCH/DELETE) or is a
filesystem page carrying_actions.pyform actions (POST-to-self on the
_action field; see is_mutating_route()). When either
CSRFMiddleware or SessionMiddlewareis missing, this ERRORs in
production and WARNs in staging (silent in development). Missing
SecurityHeadersMiddlewarealways WARNs. No issue is emitted for an app
with no mutating routes.
discovered_routes carries the filesystem PageRouteobjects (which
exposeactions); runtime router.routes expose methodsonly. Both
are scanned so a GET-only page backed by form actions is still flagged.
Note: referenced (transport) routes are included — a mutating SSE/API endpoint still needs CSRF/session protection, unlike the no-JS floor which excludes them. This is intentional and flagged for steward sign-off.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
router
|
Router
|
— | |
config
|
Any
|
— | |
middleware_list
|
list[Any]
|
— | |
discovered_routes
|
list[Any] | None
|
None
|
View source · /home/runner/work/chirp/chirp/site/../src/chirp/contracts/rules_security_stack.py:1