Skip to content

Commit e326919

Browse files
committed
Harden the deeplink suffix against traversal and delimiter injection
The splat arrives percent-decoded from the router, so the preserved remainder could contain "." / ".." segments and literal "?" or "#" characters. Traversal segments let a crafted suffix climb back out of the resolved environment path (/deeplink/apikeys/../../../../../../x resolved to /orgs/x), and a decoded "?" or "#" became part of the target's query or hash rather than its path. Drop traversal segments and re-encode each remaining segment when rebuilding the path. The redirect can no longer leave the resolved environment path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MvNi2jRtYatPQdXFJRK9SW
1 parent d2efe29 commit e326919

1 file changed

Lines changed: 12 additions & 9 deletions

File tree

apps/webapp/app/routes/deeplink.$.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,24 @@ import { newOrganizationPath, newProjectPath, v3EnvironmentPath } from "~/utils/
1414
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
1515
const user = await requireUser(request);
1616

17-
const segments = (params["*"] ?? "").split("/").filter(Boolean);
18-
//deeper segments are kept, so /deeplink/runs/run_123 reaches the run
19-
const page = ENV_PAGE_SEGMENTS.has(segments[0] ?? "") ? segments.join("/") : undefined;
17+
//traversal segments are dropped so a crafted suffix can't climb out of the environment path
18+
const segments = (params["*"] ?? "")
19+
.split("/")
20+
.filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
21+
//deeper segments are kept, so /deeplink/runs/run_123 reaches the run. They arrive decoded, so
22+
//they're re-encoded: a "?" or "#" in a segment must not become the target's query or hash.
23+
const page = ENV_PAGE_SEGMENTS.has(segments[0] ?? "")
24+
? segments.map(encodeURIComponent).join("/")
25+
: undefined;
26+
27+
const { search } = new URL(request.url);
2028

2129
const presenter = new SelectBestEnvironmentPresenter();
2230
try {
2331
const { project, organization, environment } = await presenter.call({ user });
2432
const environmentPath = v3EnvironmentPath(organization, project, environment);
2533

26-
if (!page) {
27-
return redirect(environmentPath);
28-
}
29-
30-
const { search } = new URL(request.url);
31-
return redirect(`${environmentPath}/${page}${search}`);
34+
return redirect(page ? `${environmentPath}/${page}${search}` : environmentPath);
3235
} catch (_e) {
3336
//the presenter throws when the user has no projects, same as the dashboard index
3437
const organization = await prisma.organization.findFirst({

0 commit comments

Comments
 (0)