From efbb81c0363b94559f98f6f1ba5b06911371eada Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 30 Oct 2025 17:48:29 -0700 Subject: [PATCH 1/3] fix team invites --- .../(main)/(protected)/(outside-dashboard)/projects/actions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts index 5da92edd66..e4a3d6493b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts @@ -29,7 +29,8 @@ export async function listInvitations(teamId: string) { })); } -export async function inviteUser(teamId: string, email: string, callbackUrl: string) { +export async function inviteUser(teamId: string, email: string, origin: string) { + const callbackUrl = new URL(stackServerApp.urls.teamInvitation, origin).toString(); const user = await stackServerApp.getUser(); const team = await user?.getTeam(teamId); if (!team) { From ac7bf4c8e70c014f70481089b1c008273d3fc41e Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 5 Nov 2025 10:43:13 -0800 Subject: [PATCH 2/3] validate callback url --- .../(outside-dashboard)/projects/actions.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts index e4a3d6493b..965406a2d5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts @@ -1,5 +1,52 @@ "use server"; import { stackServerApp } from "@/stack"; +import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls"; + +async function assertTrustedOrigin(teamId: string, origin: string): Promise { + const originUrl = createUrlIfValid(origin); + if (!originUrl) { + throw new Error("Invalid origin"); + } + + const user = await stackServerApp.getUser({ or: "throw" }); + const ownedProjects = await user.listOwnedProjects(); + const relevantProjects = ownedProjects.filter(project => project.ownerTeamId === teamId); + const projectsToCheck = relevantProjects.length > 0 ? relevantProjects : ownedProjects; + + if (projectsToCheck.some(project => project.config.allowLocalhost) && isLocalhost(originUrl)) { + return originUrl; + } + + const isTrusted = projectsToCheck.some(project => + project.config.domains.some(({ domain }) => domainMatches(originUrl, domain)) + ); + + if (!isTrusted) { + throw new Error("Origin is not a trusted domain"); + } + + return originUrl; +} + +function domainMatches(origin: URL, pattern: string): boolean { + const configuredUrl = createUrlIfValid(pattern); + if (configuredUrl) { + return configuredUrl.protocol === origin.protocol && configuredUrl.host === origin.host; + } + + const match = pattern.match(/^([^:]+:\/\/)([^/]+)$/); + if (!match) { + return false; + } + + const [, protocol, hostPattern] = match; + if (origin.protocol + "//" !== protocol) { + return false; + } + + const target = hostPattern.includes(":") ? origin.host : origin.hostname; + return matchHostnamePattern(hostPattern, target); +} export async function revokeInvitation(teamId: string, invitationId: string) { "use server"; @@ -30,7 +77,8 @@ export async function listInvitations(teamId: string) { } export async function inviteUser(teamId: string, email: string, origin: string) { - const callbackUrl = new URL(stackServerApp.urls.teamInvitation, origin).toString(); + const originUrl = await assertTrustedOrigin(teamId, origin); + const callbackUrl = new URL(stackServerApp.urls.teamInvitation, originUrl).toString(); const user = await stackServerApp.getUser(); const team = await user?.getTeam(teamId); if (!team) { From b3d6a9322f05225f533bba068768cb5cac359737 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 5 Nov 2025 12:35:41 -0800 Subject: [PATCH 3/3] add test to ensure untrusted callbacks error --- .../(outside-dashboard)/projects/actions.ts | 50 +------------------ .../endpoints/api/v1/team-invitations.test.ts | 30 +++++++++++ 2 files changed, 31 insertions(+), 49 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts index 965406a2d5..e4a3d6493b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts @@ -1,52 +1,5 @@ "use server"; import { stackServerApp } from "@/stack"; -import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls"; - -async function assertTrustedOrigin(teamId: string, origin: string): Promise { - const originUrl = createUrlIfValid(origin); - if (!originUrl) { - throw new Error("Invalid origin"); - } - - const user = await stackServerApp.getUser({ or: "throw" }); - const ownedProjects = await user.listOwnedProjects(); - const relevantProjects = ownedProjects.filter(project => project.ownerTeamId === teamId); - const projectsToCheck = relevantProjects.length > 0 ? relevantProjects : ownedProjects; - - if (projectsToCheck.some(project => project.config.allowLocalhost) && isLocalhost(originUrl)) { - return originUrl; - } - - const isTrusted = projectsToCheck.some(project => - project.config.domains.some(({ domain }) => domainMatches(originUrl, domain)) - ); - - if (!isTrusted) { - throw new Error("Origin is not a trusted domain"); - } - - return originUrl; -} - -function domainMatches(origin: URL, pattern: string): boolean { - const configuredUrl = createUrlIfValid(pattern); - if (configuredUrl) { - return configuredUrl.protocol === origin.protocol && configuredUrl.host === origin.host; - } - - const match = pattern.match(/^([^:]+:\/\/)([^/]+)$/); - if (!match) { - return false; - } - - const [, protocol, hostPattern] = match; - if (origin.protocol + "//" !== protocol) { - return false; - } - - const target = hostPattern.includes(":") ? origin.host : origin.hostname; - return matchHostnamePattern(hostPattern, target); -} export async function revokeInvitation(teamId: string, invitationId: string) { "use server"; @@ -77,8 +30,7 @@ export async function listInvitations(teamId: string) { } export async function inviteUser(teamId: string, email: string, origin: string) { - const originUrl = await assertTrustedOrigin(teamId, origin); - const callbackUrl = new URL(stackServerApp.urls.teamInvitation, originUrl).toString(); + const callbackUrl = new URL(stackServerApp.urls.teamInvitation, origin).toString(); const user = await stackServerApp.getUser(); const team = await user?.getTeam(teamId); if (!team) { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts index fe01b128ef..c52f1932f7 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts @@ -525,3 +525,33 @@ it("errors with item_quantity_insufficient_amount when accepting invite without } `); }); + +it("should error when untrusted callback URL is provided", async ({ expect }) => { + const { teamId } = await Team.create(); + const receiveMailbox = createMailbox(); + + backendContext.set({ userAuth: null }); + const sendTeamInvitationResponse = await niceBackendFetch("/api/v1/team-invitations/send-code", { + method: "POST", + accessType: "server", + body: { + email: receiveMailbox.emailAddress, + team_id: teamId, + callback_url: "https://malicious.com/callback", + }, + }); + + expect(sendTeamInvitationResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "REDIRECT_URL_NOT_WHITELISTED", + "error": "Redirect URL not whitelisted. Did you forget to add this domain to the trusted domains list on the Stack Auth dashboard?", + }, + "headers": Headers { + "x-stack-known-error": "REDIRECT_URL_NOT_WHITELISTED", +