11"""
2- Static analysis test: verify @login_optionally_required is always applied
3- AFTER (inner to) @blueprint.route(), not before it.
2+ Static analysis test: verify @blueprint.route() is always the outermost
3+ decorator on a view, so nothing sits above it.
44
5- In Flask, @route() must be the outermost decorator because it registers
6- whatever function it receives. If @login_optionally_required is placed
7- above @route(), the raw unprotected function gets registered and auth is
8- silently bypassed (GHSA-jmrh-xmgh-x9j4).
5+ In Flask, @route() must be outermost because it registers whatever function
6+ it receives and then returns that function unchanged. Any decorator placed
7+ above @route() is applied only to the module-level name, never to the view
8+ the blueprint actually dispatches to — so it is silently dead code. When the
9+ dead decorator is an auth wrapper, the route is left unprotected
10+ (GHSA-jmrh-xmgh-x9j4).
911
1012Correct order (route outermost, auth inner):
1113 @blueprint.route('/path')
1214 @login_optionally_required
1315 def view(): ...
1416
1517Wrong order (auth never called):
16- @login_optionally_required ← registered by route, then discarded
18+ @login_optionally_required ← discarded; route registered the raw fn
1719 @blueprint.route('/path')
1820 def view(): ...
21+
22+ This check is deliberately name-agnostic. An earlier version matched only
23+ the literal name `login_optionally_required`, which missed four routes using
24+ plain flask_login `login_required` (GHSA-q85w-c766-h5g8) — an allowlist of
25+ decorator names only ever catches the names someone remembered to add. We
26+ now flag *any* non-route decorator above @route, which also covers attribute
27+ forms (@flask_login.login_required), aliased imports, and non-auth
28+ decorators that are equally dead up there.
29+
30+ Stacked @route decorators are a legitimate Flask idiom and are exempt.
1931"""
2032
2133import ast
@@ -35,11 +47,6 @@ def _is_route_decorator(node: ast.expr) -> bool:
3547 )
3648
3749
38- def _is_auth_decorator (node : ast .expr ) -> bool :
39- """Return True if the decorator is @login_optionally_required."""
40- return isinstance (node , ast .Name ) and node .id == "login_optionally_required"
41-
42-
4350def collect_violations () -> list [str ]:
4451 violations = []
4552
@@ -54,20 +61,22 @@ def collect_violations() -> list[str]:
5461 continue
5562
5663 decorators = node .decorator_list
57- auth_indices = [i for i , d in enumerate (decorators ) if _is_auth_decorator (d )]
5864 route_indices = [i for i , d in enumerate (decorators ) if _is_route_decorator (d )]
65+ if not route_indices :
66+ continue
5967
60- # Bad order: auth decorator appears at a lower index (higher up) than a route decorator
61- for auth_idx in auth_indices :
62- for route_idx in route_indices :
63- if auth_idx < route_idx :
64- rel = path .relative_to (REPO_ROOT )
65- violations .append (
66- f"{ rel } :{ node .lineno } — `{ node .name } `: "
67- f"@login_optionally_required (line { decorators [auth_idx ].lineno } ) "
68- f"is above @route (line { decorators [route_idx ].lineno } ); "
69- f"auth wrapper will never be called"
70- )
68+ # Everything above the last @route is discarded by the registration.
69+ # Other @route decorators up there are fine — stacking routes is normal.
70+ last_route = max (route_indices )
71+ for i , decorator in enumerate (decorators ):
72+ if i < last_route and not _is_route_decorator (decorator ):
73+ rel = path .relative_to (REPO_ROOT )
74+ violations .append (
75+ f"{ rel } :{ node .lineno } — `{ node .name } `: "
76+ f"@{ ast .unparse (decorator )} (line { decorator .lineno } ) is above @route "
77+ f"(line { decorators [last_route ].lineno } ); it will never be applied "
78+ f"to the registered view"
79+ )
7180
7281 return violations
7382
@@ -76,9 +85,10 @@ def test_auth_decorator_order():
7685 violations = collect_violations ()
7786 if violations :
7887 msg = (
79- "\n \n Found routes where @login_optionally_required is placed ABOVE @blueprint.route().\n "
80- "This silently disables authentication — @route() registers the raw function\n "
81- "and the auth wrapper is never called.\n \n "
88+ "\n \n Found decorators placed ABOVE @blueprint.route().\n "
89+ "@route() registers the raw function and returns it unchanged, so anything\n "
90+ "above it is never applied to the view Flask dispatches to. If the decorator\n "
91+ "is an auth wrapper, the route is left completely unauthenticated.\n \n "
8292 "Fix: move @blueprint.route() to be the outermost (topmost) decorator.\n \n "
8393 + "\n " .join (f" • { v } " for v in violations )
8494 )
0 commit comments