diff --git a/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.spec.ts b/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.spec.ts new file mode 100644 index 0000000000..4dd5e534ee --- /dev/null +++ b/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.spec.ts @@ -0,0 +1,65 @@ +import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed"; + +describe("isOriginAllowed", () => { + describe("is allowed", () => { + it("should return true for exact match without wildcard", () => { + const allowedOrigins = ["https://app.cal.com/callback"]; + const origin = "https://app.cal.com/callback"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should return true for wildcard domain match", () => { + const allowedOrigins = ["*.multiscreen.d1test.biz"]; + const origin = "https://sub.multiscreen.d1test.biz"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should return true for wildcard pattern matching any domain", () => { + const allowedOrigins = ["*/callback"]; + const origin = "https://another.com/callback"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should handle a wildcard only pattern", () => { + const allowedOrigins = ["*"]; + const origin = "https://any.domain.com/anypath"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should handle multiple allowed URIs where only one matches", () => { + const allowedOrigins = [ + "https://app.cal.com/other", + "*.notthisone.com", + "https://app.cal.com/callback", + ]; + const origin = "https://app.cal.com/callback"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should handle patterns with multiple wildcards correctly", () => { + const allowedOrigins = ["https://*.mydomain.com/*"]; + const origin = "https://sub.mydomain.com/path/to/resource"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + }); + + describe("is not allowed", () => { + it("should return false if no allowed patterns match", () => { + const allowedOrigins = ["https://app.cal.com/callback", "*.multiscreen.d1test.biz", "*/callback"]; + const origin = "https://unknown.com"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(false); + }); + + it("should return false if wildcard pattern doesn't match the given origin", () => { + const allowedOrigins = ["*.example.com"]; + const origin = "https://notexample.org"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(false); + }); + + it("should handle empty allowedUris array correctly", () => { + const allowedOrigins: string[] = []; + const origin = "https://app.cal.com/callback"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(false); + }); + }); +}); diff --git a/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.ts b/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.ts new file mode 100644 index 0000000000..007a0a56f4 --- /dev/null +++ b/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.ts @@ -0,0 +1,18 @@ +export function isOriginAllowed(origin: string, allowedOrigins: string[]): boolean { + return allowedOrigins.some((allowedOrigin) => { + if (allowedOrigin.includes("*")) { + return wildcardToRegex(allowedOrigin).test(origin); + } + return origin === allowedOrigin; + }); +} + +function wildcardToRegex(pattern: string): RegExp { + const escaped = escapeRegex(pattern); + const regexPattern = "^" + escaped.replace(/\\\*/g, ".*") + "$"; + return new RegExp(regexPattern); +} + +function escapeRegex(str: string): string { + return str.replace(/[.+*?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts index b159f0baee..44d165cae2 100644 --- a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts @@ -1,5 +1,6 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; import { AuthMethods } from "@/lib/enums/auth-methods"; +import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed"; import { BaseStrategy } from "@/lib/passport/strategies/types"; import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { DeploymentsService } from "@/modules/deployments/deployments.service"; @@ -167,7 +168,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") throw new UnauthorizedException("OAuth client not found given the access token"); } - if (origin && !client.redirectUris.some((uri) => uri.startsWith(origin))) { + if (origin && !isOriginAllowed(origin, client.redirectUris)) { throw new UnauthorizedException( `Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client.` ); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts index 565d7ac94f..37ba6fc00a 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts @@ -1,5 +1,6 @@ import { getEnv } from "@/env"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; @@ -62,7 +63,7 @@ export class OAuthFlowController { throw new BadRequestException(`OAuth client with ID '${clientId}' not found`); } - if (!oauthClient?.redirectUris.includes(body.redirectUri)) { + if (!isOriginAllowed(body.redirectUri, oauthClient.redirectUris)) { throw new BadRequestException("Invalid 'redirect_uri' value."); }