Skip to content

app: Fix ARN encoding in web launcher URL#64827

Merged
juliaogris merged 2 commits intomasterfrom
julia/app/aws-rolepicker-fix
Apr 1, 2026
Merged

app: Fix ARN encoding in web launcher URL#64827
juliaogris merged 2 commits intomasterfrom
julia/app/aws-rolepicker-fix

Conversation

@juliaogris
Copy link
Copy Markdown
Contributor

@juliaogris juliaogris commented Mar 19, 2026

When an AWS console app role ARN contains a / in the path component (e.g.
arn:aws:iam::123:role/my-role), the web launcher redirect truncates the ARN
at the slash.

The configured IAM role ARN must appear exactly in the session cert for the
access check to pass. When the cert carries a truncated value
(arn:aws:iam::123:role instead of arn:aws:iam::123:role/my-role), the app
service rejects the mismatch and the user gets "Not Found".

This is a regression from the react-router v7 upgrade
(PR gravitational/teleport#63496).

The app launcher route is /web/launch/:fqdn/:clusterId?/:publicAddr?/:arn?.
Both v5 and v7 encode / as %2F in generatePath. The difference is
useParams(): v5 (via path-to-regexp 1.9.0) returned the raw percent-encoded
value, while v7 (which dropped path-to-regexp) auto-decodes it. This changes
how the ARN travels through the auth exchange.

Confirmed by bisecting between the commit before the v7 upgrade (c75ea42)
and the upgrade commit itself (6298fe2). The cert.create audit event shows
the difference:

Before (v5): route_to_app:map[aws_role_arn:arn:aws:iam::123456789012:role/my-role ...]
After (v7):  route_to_app:map[aws_role_arn:arn:aws:iam::123456789012:role ...]

Percent-encode the ARN with url.PathEscape before appending it to the URL
path, and replace path.Join with strings.Join plus explicit RawPath/Path
setting so that %2F survives URL serialization.

react-router v5 vs v7 vs v7 with this fix

v5 (react-router 5.3.4, path-to-regexp 1.9.0):

  1. (JS) generatePath encodes /%2F. URL: .../arn%3Aaws%3Aiam%3A%3A123%3Arole%2Fmy-role
  2. (JS) useParams() does NOT decode → params.arn = arn%3Aaws%3Aiam%3A%3A123%3Arole%2Fmy-role 🔑
  3. (JS) searchParams.set('arn', params.arn) double-encodes → arn=arn%253A...%252Fmy-role
  4. (Go) q.Get("arn") decodes one layer → arn%3Aaws%3Aiam%3A%3A123%3Arole%2Fmy-role
  5. (Go) path.Join sees %2F as literal characters, not a slash → ARN stays as one segment ✅
  6. (Go) Redirect URL path has %2F intact
  7. (JS) Second trip: :arn? matches the whole thing because %2F is not a /
  8. (JS) decodeURIComponent(params.arn)arn:aws:iam::123:role/my-role
  9. (JS) createAppSession POSTs arn: "arn:aws:iam::123:role/my-role"
  10. (Go) Auth server issues X.509 cert with aws_role_arn = arn:aws:iam::123:role/my-role
  11. (Go) App service compares cert ARN against configured role → exact match, access granted ✅

v7 broken (react-router 7.13.0, no path-to-regexp):

  1. (JS) generatePath encodes /%2F. URL: same as v5
  2. (JS) useParams() auto-decodes %2Fparams.arn = arn:aws:iam::123:role/my-role (literal /) 💥
  3. (JS) searchParams.set('arn', params.arn) single-encodes → arn=arn%3A...role%2Fmy-role
  4. (Go) q.Get("arn") decodes → arn:aws:iam::123:role/my-role (literal /)
  5. (Go) path.Join outputs path with literal / inside the ARN, indistinguishable from structural slashes
  6. (Go) Redirect URL: .../arn:aws:iam::123:role/my-role?state=TOKEN
  7. (JS) Second trip: :arn? matches [^/]+, captures only arn:aws:iam::123:role. my-role becomes a stray segment, discarded 💥
  8. (JS) decodeURIComponent is a no-op on the already-truncated value
  9. (JS) createAppSession POSTs arn: "arn:aws:iam::123:role"
  10. (Go) Auth server issues X.509 cert with aws_role_arn = arn:aws:iam::123:role
  11. (Go) App service compares cert ARN against configured role → mismatch, "Not Found" ❌

v7 with this fix:

  1. (JS) generatePath encodes /%2F. URL: same as above
  2. (JS) useParams() auto-decodes %2Fparams.arn = arn:aws:iam::123:role/my-role (literal /)
  3. (JS) searchParams.set('arn', params.arn) single-encodes → arn=arn%3A...role%2Fmy-role
  4. (Go) q.Get("arn") decodes → arn:aws:iam::123:role/my-role (literal /)
  5. (Go) url.PathEscape(req.arn) re-encodes /%2F 🔧
  6. (Go) strings.Join preserves %2F as literal characters (replaces path.Join) 🔧
  7. (Go) u.RawPath set with encoded value. url.URL.String() uses RawPath%2F intact 🔧
  8. (JS) Second trip: URL has %2F in path. :arn? matches the whole encoded segment. useParams() auto-decodes → full ARN ✅
  9. (JS) decodeURIComponent(params.arn) is a no-op (already decoded)
  10. (JS) createAppSession POSTs arn: "arn:aws:iam::123:role/my-role"
  11. (Go) Auth server issues X.509 cert with aws_role_arn = arn:aws:iam::123:role/my-role
  12. (Go) App service compares cert ARN against configured role → exact match, access granted ✅

Issue: #64823

Manual Test Plan

Test Environment

Local Teleport instance with an AWS console app configured with an IAM
role whose ARN contains a path component
(arn:aws:iam::<ACCOUNT>:role/<ROLE_NAME>).

Test Cases

  • Open the web UI, click the awsconsole app, select the role from
    the picker. The AWS console opens instead of "Not Found".
  • Same test in an incognito window (exercises the full auth exchange
    round-trip through makeAppRedirectURL).
  • CLI access: tsh apps login awsconsole --aws-role <ARN> followed
    by tsh aws sts get-caller-identity returns the assumed role.
  • Launch a non-AWS app from the web UI. No regression.

@juliaogris juliaogris added no-changelog Indicates that a PR does not require a changelog entry no-test-plan Bypasses the test plan validation bot labels Mar 24, 2026
@juliaogris juliaogris force-pushed the julia/app/aws-rolepicker-fix branch 2 times, most recently from 7dbf35b to 2ac8725 Compare March 24, 2026 23:19
@juliaogris juliaogris removed the no-test-plan Bypasses the test plan validation bot label Mar 30, 2026
@juliaogris juliaogris force-pushed the julia/app/aws-rolepicker-fix branch from 9f568d2 to f6d0658 Compare March 30, 2026 04:40
@juliaogris
Copy link
Copy Markdown
Contributor Author

@codex - could I have a review please?

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@juliaogris juliaogris marked this pull request as ready for review March 30, 2026 05:01
@github-actions github-actions Bot requested review from alexhemard and avatus March 30, 2026 05:01
chatgpt-codex-connector[bot]

This comment was marked as outdated.

@juliaogris juliaogris force-pushed the julia/app/aws-rolepicker-fix branch from cc7813d to 2f8e005 Compare March 30, 2026 06:33
@chatgpt-codex-connector

This comment was marked as outdated.

@juliaogris juliaogris marked this pull request as draft March 30, 2026 06:45
When an AWS console app role ARN contains a `/` in the path component
(e.g. `arn:aws:iam::123:role/my-role`), the web launcher redirect
truncates the ARN at the slash.

This is a regression from the react-router v7 upgrade (PR #63496).
Both v5 and v7 encode `/` as `%2F` in the URL path via `generatePath`.
The difference is in `useParams()`: v5 returned the raw
percent-encoded value, v7 auto-decodes it.

In v5, `useParams().arn` was still percent-encoded (e.g.
`role%2Fmy-role`). `getNewAuthExchangeUrl` put that encoded value into
the query string, so Go's `q.Get("arn")` decoded one layer and got
`role%2Fmy-role` (literal `%2F`, not a slash). `path.Join` treated
`%2F` as ordinary characters and the full ARN survived.

In v7, `useParams().arn` is auto-decoded to `role/my-role` (literal
`/`). `getNewAuthExchangeUrl` puts that decoded value into the query
string, Go's `q.Get("arn")` gets a literal `/`, and `path.Join` in
`makeAppRedirectURL` splits the ARN at that slash. The redirect URL
carries a truncated ARN, the session cert is issued with the truncated
value, and the access check rejects the mismatch.

Percent-encode the ARN with `url.PathEscape` before appending it to
the URL path, and replace `path.Join` with `strings.Join` plus
explicit `RawPath`/`Path` setting so that `%2F` survives URL
serialization.
@juliaogris juliaogris force-pushed the julia/app/aws-rolepicker-fix branch from 2f8e005 to f79b396 Compare March 31, 2026 04:28
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@juliaogris juliaogris marked this pull request as ready for review March 31, 2026 05:13
claude[bot]

This comment was marked as spam.

@juliaogris
Copy link
Copy Markdown
Contributor Author

@claude review please

@github-actions github-actions Bot requested a review from alexhemard March 31, 2026 05:13
@github-actions github-actions Bot requested a review from ryanclark March 31, 2026 05:13
chatgpt-codex-connector[bot]

This comment was marked as resolved.

Comment thread lib/web/app/handler.go
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My previous concern about the PathUnescape fallback has been addressed — all segments are now url.PathEscaped before building RawPath, making the error path unreachable. LGTM.

Extended reasoning...

Overview

This PR fixes a regression introduced by the react-router v7 upgrade: useParams() in v7 auto-decodes percent-encoded path segments, causing ARNs with / to be split at the slash during the redirect round-trip. The fix applies url.PathEscape to all URL path segments on the Go side and sets u.RawPath explicitly so that %2F survives serialization.

Security risks

The changed code handles user-controlled input (ARN, cluster name, public address) in URL construction. The url.PathEscape applied to all segments prevents injection of bare slashes or invalid percent sequences into the redirect URL path. No auth bypass or privilege escalation vectors are introduced.

Level of scrutiny

This is a focused regression fix in the app launcher URL construction path. The logic is well-documented with a detailed step-by-step breakdown in the PR description, and the fix is confirmed by bisection. The change is confined to makeAppRedirectURL in handler.go and corresponding JS/test updates.

Prior review resolution

My previous inline comment flagged that an intermediate version had a u.Path = u.RawPath fallback on PathUnescape error which would corrupt the ARN encoding. The author addressed this by applying url.PathEscape to all path segments (not just the ARN) before building RawPath. Since url.PathEscape guarantees valid percent-encoding, url.PathUnescape now always succeeds and the discarded error is genuinely unreachable. The Codex P2 comment about unescaped non-ARN segments was resolved by the same change.

Escape every segment in the redirect URL path, not just the ARN.
This prevents any unexpected percent sequences in cluster names
or public addresses from being emitted raw in the Location header.
@juliaogris juliaogris force-pushed the julia/app/aws-rolepicker-fix branch from 3e196d4 to 12cbf72 Compare April 1, 2026 03:10
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the url.PathEscape-all-segments approach I recommended has been correctly implemented and the broken fallback removed.

Extended reasoning...

Overview

This PR fixes a regression in the AWS console app launcher introduced by the react-router v5→v7 upgrade. The core issue: react-router v7's useParams() auto-decodes percent-encoded path segments (e.g. %2F/), so ARNs with slashes were being truncated at the slash on the second round-trip. The fix is applied in Go (makeAppRedirectURL) by re-escaping the decoded ARN with url.PathEscape before embedding it in RawPath, and by switching from path.Join (which treats / structurally) to strings.Join (which preserves literal %2F). The JS side already has the right behavior since useParams() decodes the segment for createAppSession.

Security risks

No security risks. The changed code builds a redirect URL for the app launcher flow. URL encoding is tightened (all segments now go through url.PathEscape), which if anything reduces exposure to path-traversal via crafted cluster or publicAddr values. No authentication logic, crypto, or authorization checks are modified.

Level of scrutiny

Medium. The change touches URL construction in the app auth redirect flow, which is a user-visible path but not a security boundary itself. The PR description includes a detailed step-by-step trace of the encoding flow across all three scenarios (v5, v7-broken, v7-fixed), which is unusually thorough and makes the correctness easy to verify.

Other factors

My previous review identified that the intermediate version (with u.Path = u.RawPath fallback) was actively wrong — it would double-encode %2F to %252F when triggered. The author resolved this exactly as I suggested: apply url.PathEscape to all non-ARN segments so the error path is never reachable and the fallback was removed. The inline comment was resolved and Codex also gave a clean pass on the updated revision. New tests cover multi-level ARN paths, special characters, and a round-trip test through React Router routing. No bugs found by the bug hunting system.

@juliaogris juliaogris added this pull request to the merge queue Apr 1, 2026
Merged via the queue into master with commit e090811 Apr 1, 2026
50 checks passed
@juliaogris juliaogris deleted the julia/app/aws-rolepicker-fix branch April 1, 2026 22:45
mmcallister pushed a commit that referenced this pull request Apr 24, 2026
* app: Fix ARN encoding in web launcher redirect URL

When an AWS console app role ARN contains a `/` in the path component
(e.g. `arn:aws:iam::123:role/my-role`), the web launcher redirect
truncates the ARN at the slash.

This is a regression from the react-router v7 upgrade (PR #63496).
Both v5 and v7 encode `/` as `%2F` in the URL path via `generatePath`.
The difference is in `useParams()`: v5 returned the raw
percent-encoded value, v7 auto-decodes it.

In v5, `useParams().arn` was still percent-encoded (e.g.
`role%2Fmy-role`). `getNewAuthExchangeUrl` put that encoded value into
the query string, so Go's `q.Get("arn")` decoded one layer and got
`role%2Fmy-role` (literal `%2F`, not a slash). `path.Join` treated
`%2F` as ordinary characters and the full ARN survived.

In v7, `useParams().arn` is auto-decoded to `role/my-role` (literal
`/`). `getNewAuthExchangeUrl` puts that decoded value into the query
string, Go's `q.Get("arn")` gets a literal `/`, and `path.Join` in
`makeAppRedirectURL` splits the ARN at that slash. The redirect URL
carries a truncated ARN, the session cert is issued with the truncated
value, and the access check rejects the mismatch.

Percent-encode the ARN with `url.PathEscape` before appending it to
the URL path, and replace `path.Join` with `strings.Join` plus
explicit `RawPath`/`Path` setting so that `%2F` survives URL
serialization.

* app: Percent-encode all path segments in launcher redirect

Escape every segment in the redirect URL path, not just the ARN.
This prevents any unexpected percent sequences in cluster names
or public addresses from being emitted raw in the Location header.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog Indicates that a PR does not require a changelog entry size/md ui

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants