-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix: preserve authorizer config for overlapping API routes #9166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 3 commits
bfb7b0e
c5a226e
df9f9b1
6b5599b
6f849fb
4e8413c
2535804
3a8614f
a227931
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -217,7 +217,8 @@ def get_api(self) -> Api: | |
| @staticmethod | ||
| def normalize_cors_methods(routes: List[Route], cors: Optional[Cors]) -> List[Route]: | ||
| """ | ||
| Adds OPTIONS method to all the route methods if cors exists | ||
| Adds OPTIONS method to route methods if cors exists while preserving | ||
| explicit OPTIONS ownership within a route group. | ||
|
|
||
| Parameters | ||
| ----------- | ||
|
|
@@ -229,15 +230,29 @@ def normalize_cors_methods(routes: List[Route], cors: Optional[Cors]) -> List[Ro | |
|
|
||
| Return | ||
| ------- | ||
| A list of routes without duplicate routes with the same function_name and method | ||
| A list of routes with at most one OPTIONS owner per route group | ||
| """ | ||
| if not cors: | ||
| return routes | ||
|
|
||
| def add_options_to_route(route: Route) -> Route: | ||
| if "OPTIONS" not in route.methods: | ||
| route.methods.append("OPTIONS") | ||
| return route | ||
| grouped_routes: Dict[str, List[Route]] = {} | ||
|
|
||
| return routes if not cors else [add_options_to_route(route) for route in routes] | ||
| for route in routes: | ||
| key = "{}-{}-{}-{}".format(route.stack_path, route.function_name, route.path, route.operation_name or "") | ||
| grouped_routes.setdefault(key, []).append(route) | ||
|
|
||
| result: List[Route] = [] | ||
|
|
||
| for route_group in grouped_routes.values(): | ||
| options_claimed = any("OPTIONS" in route.methods for route in route_group) | ||
|
|
||
| for route in route_group: | ||
| if not options_claimed: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] The synthesized OPTIONS owner is chosen by group iteration order, so it can land on an authorizer-protected route even when an unauthenticated sibling exists in the same group. After the dedupe_function_routes change, a group can legitimately contain several routes with different authorizers. Consider an AWS::Serverless::Api with Cors set and a DefinitionBody where /x get has If that first route is get, preflight is served by a route carrying authorizer_object, and _request_handler invokes the Lambda authorizer for it. Browser preflight requests do not carry credentials, so the preflight fails with 401/403 and the real request is never sent. Before this PR these routes were collapsed into one, so this selection did not exist. Since keeping preflight out of the authorizer path is the point of the PR, pick the owner deliberately rather than by position: for route_group in grouped_routes.values():
options_claimed = any("OPTIONS" in route.methods for route in route_group)
if not options_claimed:
# Prefer a route without an authorizer so CORS preflight is not authorized
owner = next(
(route for route in route_group if route.authorizer_object is None),
route_group[0],
)
owner.methods.append("OPTIONS")
result.extend(route_group) |
||
| route.methods.append("OPTIONS") | ||
| options_claimed = True | ||
| result.append(route) | ||
|
|
||
| return result | ||
|
|
||
| @staticmethod | ||
| def dedupe_function_routes(routes: List[Route]) -> List[Route]: | ||
|
|
@@ -251,30 +266,73 @@ def dedupe_function_routes(routes: List[Route]) -> List[Route]: | |
| ------- | ||
| A list of routes without duplicate routes with the same stack_path, function_name and method | ||
| """ | ||
| grouped_routes: Dict[str, Route] = {} | ||
| grouped_routes: Dict[str, List[Route]] = {} | ||
|
|
||
| for route in routes: | ||
| key = "{}-{}-{}-{}".format(route.stack_path, route.function_name, route.path, route.operation_name or "") | ||
| config = grouped_routes.get(key, None) | ||
| methods = route.methods | ||
| if config: | ||
| methods += config.methods | ||
| sorted_methods = sorted(methods) | ||
| # Prefer route-specific CORS over None | ||
| cors = route.cors if route.cors is not None else (config.cors if config else None) | ||
| grouped_routes[key] = Route( | ||
| function_name=route.function_name, | ||
| path=route.path, | ||
| methods=sorted_methods, | ||
| event_type=route.event_type, | ||
| payload_format_version=route.payload_format_version, | ||
| operation_name=route.operation_name, | ||
| stack_path=route.stack_path, | ||
| authorizer_name=route.authorizer_name, | ||
| authorizer_object=route.authorizer_object, | ||
| cors=cors, | ||
| grouped_routes.setdefault(key, []).append(route) | ||
|
|
||
| result: List[Route] = [] | ||
|
|
||
| def has_same_authorizer(first: Route, second: Route) -> bool: | ||
| return ( | ||
| first.authorizer_name == second.authorizer_name and first.authorizer_object == second.authorizer_object | ||
| ) | ||
| return list(grouped_routes.values()) | ||
|
|
||
| for route_group in grouped_routes.values(): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] The split logic here is correct, but for SAM templates it is only reachable when the explicit
for config in all_configs:
# Normalize the methods before de-duping to allow an ANY method in implicit API to override a regular HTTP
# method on explicit route.
for normalized_method in config.methods:
key = config.path + normalized_method
...
all_routes[key] = config
result = set(all_routes.values()) # Assign to a set() to de-dupeBecause Concretely, for the template in #9165:
The new tests all call |
||
| merged_routes: List[Route] = [] | ||
| group_cors = next((route.cors for route in route_group if route.cors is not None), None) | ||
|
|
||
| # Process broader routes first so a more specific route can own | ||
| # overlapping methods, e.g. explicit OPTIONS overriding ANY. | ||
| for route in sorted(route_group, key=lambda item: len(item.methods), reverse=True): | ||
| methods = list(dict.fromkeys(route.methods)) | ||
|
|
||
| for existing_route in merged_routes: | ||
| if not has_same_authorizer(existing_route, route): | ||
| existing_route.methods = [method for method in existing_route.methods if method not in methods] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] Stripping the overlapping methods makes the split disjoint here, but get_api() runs the CORS normalization immediately after dedupe (lines 195-196): routes = self.dedupe_function_routes(self.routes)
routes = self.normalize_cors_methods(routes, self.cors)and normalize_cors_methods appends def add_options_to_route(route: Route) -> Route:
if "OPTIONS" not in route.methods:
route.methods.append("OPTIONS")
return routeSo whenever the API has CORS configured, the authorizer-protected route regains Consider making the released methods explicit instead of relying on ordering — e.g. track the methods a route gave up during dedupe and have normalize_cors_methods skip injecting |
||
|
|
||
| matching_route = next( | ||
| (existing_route for existing_route in merged_routes if has_same_authorizer(existing_route, route)), | ||
| None, | ||
| ) | ||
|
|
||
| if matching_route: | ||
| matching_route.methods = sorted(set(matching_route.methods + methods)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] When two routes in a group share an authorizer, the merge only updates methods and cors on the surviving route — which is the first one created, i.e. the route with the most methods (usually the ANY route). Every other attribute of the narrower route is discarded, including payload_format_version. That one has real consequences for HTTP APIs, because a missing payload format version is treated as 2.0 (local_apigw_service.py:460): if route.event_type == Route.HTTP and route.payload_format_version in [None, "2.0"]:So an ANY route with no PayloadFormatVersion now deterministically shadows a sibling method route (same function/path/operation and same authorizer) that declares if route and route.payload_format_version and config.payload_format_version is None:
config.payload_format_version = route.payload_format_versionMirroring it in the merge branch keeps the behavior consistent: if matching_route:
matching_route.methods = sorted(set(matching_route.methods + methods))
if matching_route.payload_format_version is None:
matching_route.payload_format_version = route.payload_format_version
if route.cors is not None:
matching_route.cors = route.cors
continueThe same one-sided loss applies to use_default_authorizer, which the PR description says is preserved: it is preserved on the copy (line 302) but on a merge only the first route's value survives, and test_merges_routes_with_same_resolved_authorizer locks that in (the False from the POST route is dropped). That is currently inert because _link_authorizers() has already run, but it is worth a comment in the code so the next reader does not assume the flag is still meaningful. |
||
|
|
||
| if matching_route.payload_format_version is None: | ||
| matching_route.payload_format_version = route.payload_format_version | ||
|
|
||
| if route.cors is not None: | ||
| matching_route.cors = route.cors | ||
|
|
||
| # Authorizers are already resolved by _link_authorizers() before | ||
| # deduplication, so use_default_authorizer does not affect this merge. | ||
| continue | ||
|
|
||
| merged_routes.append( | ||
| Route( | ||
| function_name=route.function_name, | ||
| path=route.path, | ||
| methods=sorted(methods), | ||
| event_type=route.event_type, | ||
| payload_format_version=route.payload_format_version, | ||
| operation_name=route.operation_name, | ||
| stack_path=route.stack_path, | ||
| authorizer_name=route.authorizer_name, | ||
| authorizer_object=route.authorizer_object, | ||
| use_default_authorizer=route.use_default_authorizer, | ||
| cors=route.cors, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] Route-level CORS is no longer propagated across the routes in a group. The removed code deliberately merged it: # Prefer route-specific CORS over None
cors = route.cors if route.cors is not None else (config.cors if config else None)The new code only carries cors=cors if method == "OPTIONS" else None,There is no fallback to recover it. In cors = route.cors if route.cors is not None else self.api.cors
...
headers.update(cors_headers)
Compute the group's effective CORS once and apply it to every resulting route that has none of its own: group_cors = next((route.cors for route in route_group if route.cors is not None), None)
...
for merged_route in merged_routes:
if merged_route.cors is None:
merged_route.cors = group_cors
result.extend(route for route in merged_routes if route.methods) |
||
| ) | ||
| ) | ||
|
|
||
| for merged_route in merged_routes: | ||
| if merged_route.cors is None: | ||
| merged_route.cors = group_cors | ||
|
|
||
| result.extend(route for route in merged_routes if route.methods) | ||
|
|
||
| return result | ||
|
|
||
| def add_binary_media_types(self, logical_id: str, binary_media_types: Optional[List[str]]) -> None: | ||
| """ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[GENERAL] options_claimed short-circuits the new unauthenticated-owner preference whenever the group contains an ANY-derived route, because Route.normalize_method expands ANY to ANY_HTTP_METHODS, which always includes OPTIONS. So the docstring's claim that "synthesized OPTIONS prefers a route without a linked local authorizer" does not hold for the most common CORS shape.
Concretely, an AWS::Serverless::Api with Cors set and a group of ANY /x (authorizer MyAuth) plus POST /x (security: []): dedupe_function_routes strips POST from the ANY route but leaves OPTIONS on it, so options_claimed is True and OPTIONS stays owned by the authorizer-protected route. _request_handler dispatches on route.authorizer_object, so the local preflight is challenged. On AWS the Cors property causes the transform to emit an unauthenticated OPTIONS mock integration that overrides ANY, so preflight succeeds there — a browser-visible local/deployed divergence.
The check that matters is not "does any route in the group list OPTIONS" but "does any route explicitly declare OPTIONS". An OPTIONS entry that only exists because ANY was expanded is not an ownership claim, and when cors is configured it should be re-assignable to an unauthorized sibling the same way an explicitly declared OPTIONS route already takes precedence in dedupe_function_routes. As written, the preference only ever fires for groups made up entirely of single-method routes — which is what the new tests cover.