Skip to content

fix(local-apigw): treat authorizer resource ARNs as literals, not regex - #9155

Open
devteamaegis wants to merge 1 commit into
aws:developfrom
devteamaegis:fix/local-authorizer-arn-regex-escape
Open

fix(local-apigw): treat authorizer resource ARNs as literals, not regex#9155
devteamaegis wants to merge 1 commit into
aws:developfrom
devteamaegis:fix/local-authorizer-arn-regex-escape

Conversation

@devteamaegis

Copy link
Copy Markdown

What's broken

sam local start-api returns 403 for requests a Lambda authorizer explicitly allows, whenever the resource ARN contains a regex metacharacter. The everyday case is the HTTP API $default stage:

# authorizer handler
return {"principalId": "user", "policyDocument": {"Statement": [{
    "Action": "execute-api:Invoke", "Effect": "Allow",
    "Resource": "arn:aws:execute-api:us-east-1:123456789012:abc123/$default/*",
}]}}
$ sam local start-api
$ curl localhost:3000/hello
{"message":"User is not authorized to access this resource"}

It fails even when the authorizer echoes back the exact methodArn it was handed — the most common example in AWS's own docs. Deployed API Gateway allows the same request, so this is a pure local/deployed divergence.

A [ or ( in the path is worse — the request dies with re.PatternError: unterminated character set.

Why it happens

_is_resource_authorized builds a regex directly from the ARN, escaping nothing but the wildcards:

regex_method_arn = resource_arn.replace("*", ".*").replace("?", ".")
regex_method_arn += "$"

So $ becomes an end-of-string anchor, . matches any character, and [ / ( are unbalanced syntax.

The fix

re.escape() the ARN first, then translate the two IAM wildcards:

regex_method_arn = re.escape(resource_arn).replace(r"\*", ".*").replace(r"\?", ".")

* and ? keep working; everything else in the ARN is matched literally. This also stops . from over-matching — .../GET/a.c no longer authorizes .../GET/abc.

The test

test_is_resource_authorized_treats_arn_as_literal in tests/unit/local/apigw/test_lambda_authorizer.py — six cases covering $default, an echoed method ARN, [, (, literal ., and a stage that should still be denied. Five of six fail on develop:

$ pytest tests/unit/local/apigw/test_lambda_authorizer.py -k treats_arn_as_literal   # before
5 failed, 1 passed

$ pytest tests/unit/local/apigw                                                      # after
286 passed

black --check clean on both files.

_is_resource_authorized built a regular expression straight from the
Lambda authorizer's Resource ARN, escaping nothing but the wildcards.
Any regex metacharacter in the ARN was therefore interpreted as syntax.

The common case is the HTTP API '$default' stage: '$' is an end-of-string
anchor, so an Allow statement for
'arn:aws:execute-api:...:api/$default/*' never matched the method ARN and
sam local start-api returned 403 for a request that succeeds when
deployed - even when the authorizer echoed back the exact methodArn it
was handed.  A '[' or '(' in the path raised re.PatternError outright.

Escape the ARN first, then translate the '*' and '?' wildcards.

Signed-off-by: devteamaegis <devteam.aegis@gmail.com>
@github-actions github-actions Bot added area/local/start-api sam local start-api command area/local/invoke sam local invoke command area/local/start-invoke pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at. labels Aug 2, 2026
@devteamaegis
devteamaegis marked this pull request as ready for review August 3, 2026 16:45
@devteamaegis
devteamaegis requested a review from a team as a code owner August 3, 2026 16:45
@roger-zhangg

Copy link
Copy Markdown
Member

Thanks for this, and sorry for the long silence — 30 days is too long for a fix this small.

I reviewed it on a local worktree of pull/9155/head and verified the claims rather than taking them on faith. Short version: the change is correct and minimally scoped, and I could not find a second site that needs the same treatment.

The escaping covers every ARN comparison site. samcli/local/apigw/authorizers/lambda_authorizer.py:393 is the only place in the repo that builds a regex out of a resource ARN. Grepping samcli/local/apigw/ for re.match|re.search|re.fullmatch|re.compile|re.sub returns only two other hits, neither an ARN comparison:

  • lambda_authorizer.py:110 — matches an identity source against the authorizer's ValidationExpression, which is a caller-supplied regex by design.
  • path_converter.py:30-31 — path-template conversion, not ARNs.

method_arn reaches exactly one comparison, lambda_authorizer.py:396 (via is_valid_response at :355), so there is no second path left unescaped.

Both failure directions are real. With develop's version of the file and this PR's tests, 5 of 6 new cases fail:

  • the a[b case raises re.PatternError: unterminated character set at position 64 — a 500, not a 403;
  • case 4 (resource .../GET/a.c, request .../GET/abc) returns True on develop, i.e. a false allow. That is the security-relevant direction, and it is easy to hit by accident since dots in paths (/report.pdf, /v1.0/...) are ordinary.

The $default scenario is reachable end to end, worth recording for whoever backports this: _create_method_arn interpolates self.api.stage_name (samcli/local/apigw/local_apigw_service.py:261-264), and stage_name is taken verbatim from the template's StageName (samcli/lib/providers/sam_api_provider.py:370, assigned at :390). So with StageName: $default the methodArn/routeArn that SAM itself hands the authorizer contains a $, and echoing it straight back as Resource — the pattern in AWS's own examples — could never match under the old code.

Wildcards still translate. re.escape emits \* and \? on every Python we support (checked on 3.13.7), so .replace(r"\*", ".*").replace(r"\?", ".") preserves both IAM wildcards. arn:...:abc/$default/* matches arn:...:abc/$default/GET/hello after the change, and the pre-existing he?lo single-character-wildcard case at tests/unit/local/apigw/test_lambda_authorizer.py:383 still passes.

Tests run (worktree of 91f23c6e1, Python 3.13.7):

  • python -m pytest tests/unit/local/apigw -q286 passed
  • same command after merging current develop into the head locally (merged clean, no conflicts) → 286 passed
  • black --check clean on both changed files; mypy on lambda_authorizer.py surfaces only a pre-existing, unrelated error in samcli/lib/utils/graphql_api.py:27

Non-blocking notes, none of which I would hold the merge for:

  1. lambda_authorizer.py:394 appends "$", and $ also matches just before a trailing newline, so .../GET/hello\n would still match. Pre-existing, and method_arn is built from flask_request.path, so I don't believe it is reachable — but re.fullmatch(pattern, method_arn) or \Z would be strictly tighter if you're already touching the line.
  2. The six cases could be appended to the existing test_validate_is_resource_authorized parameterized list instead of a new method — that test already accepts an optional method_arn override (tests/unit/local/apigw/test_lambda_authorizer.py:438-442) and test_is_resource_authorized_treats_arn_as_literal (:492) duplicates its LambdaAuthorizer(...) setup verbatim. Purely cosmetic; I'm fine as-is.
  3. Unrelated to this PR, noticed while reading: _is_resource_authorized skips every non-Allow statement (lambda_authorizer.py:377-378), so an explicit Deny never overrides an Allow locally the way it does in deployed API Gateway. That's a separate issue — please don't expand this PR to cover it.

This looks good to me as it stands. Note that only 3 checks have reported so far; the remaining fork workflows need a maintainer approval to run before it can go in.

@devteamaegis

Copy link
Copy Markdown
Author

Thanks for the thorough review @roger-zhangg, and for verifying it on a worktree rather than taking it on faith — that write-up is more careful than the fix deserved. Agreed on leaving the scope as-is; the Deny-override behavior is a separate issue and I won't pull it in here.

On note 1: since I'm already touching that line, I'm happy to switch the trailing $ to \Z (or re.fullmatch) for the strictly-tighter match if you'd prefer it — but equally fine leaving it, given method_arn comes from flask_request.path. Your call. Otherwise it's ready whenever you can approve the fork workflows. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/local/invoke sam local invoke command area/local/start-api sam local start-api command area/local/start-invoke pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants