diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index 6bb1a94ffd9..43be71dfd17 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -1,7 +1,10 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { shopifyAuthorizeQuerySchema } from '@/lib/api/contracts/oauth-connections' +import { + shopifyAuthorizeQuerySchema, + shopifyShopDomainSchema, +} from '@/lib/api/contracts/oauth-connections' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -161,6 +164,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { cleanShop = `${cleanShop.replace('.myshopify.com', '')}.myshopify.com` } + if (!shopifyShopDomainSchema.safeParse(cleanShop).success) { + logger.warn('Rejected invalid Shopify shop domain', { shop: shopDomain }) + return NextResponse.json({ error: 'Invalid Shopify shop domain' }, { status: 400 }) + } + const baseUrl = getBaseUrl() const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify` diff --git a/apps/sim/app/api/workflows/middleware.test.ts b/apps/sim/app/api/workflows/middleware.test.ts new file mode 100644 index 00000000000..996466426da --- /dev/null +++ b/apps/sim/app/api/workflows/middleware.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for workflow access middleware — focused on the workspace-scoped + * API key boundary check in the `requireDeployment=false` branch. + * + * @vitest-environment node + */ + +import { + hybridAuthMockFns, + workflowAuthzMock, + workflowAuthzMockFns, + workflowsUtilsMock, + workflowsUtilsMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@sim/workflow-authz', () => workflowAuthzMock) +vi.mock('@/lib/api-key/service', () => ({ + authenticateApiKeyFromHeader: vi.fn(), + updateApiKeyLastUsed: vi.fn(), +})) + +import { validateWorkflowAccess } from '@/app/api/workflows/middleware' + +function makeRequest() { + return new NextRequest(new URL('https://example.com/api/workflows/wf-1/log')) +} + +describe('validateWorkflowAccess (requireDeployment=false)', () => { + beforeEach(() => { + vi.clearAllMocks() + workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({ + id: 'wf-1', + workspaceId: 'ws-A', + isDeployed: true, + }) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'wf-1', workspaceId: 'ws-A' }, + }) + }) + + it('rejects a workspace-scoped API key issued for a different workspace', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'workspace', + workspaceId: 'ws-B', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toEqual({ + message: 'API key is not authorized for this workspace', + status: 403, + }) + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('allows a workspace-scoped API key issued for the matching workspace', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'workspace', + workspaceId: 'ws-A', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toBeUndefined() + expect(result.workflow).toBeDefined() + expect(result.auth?.workspaceId).toBe('ws-A') + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: 'wf-1', + userId: 'user-1', + action: 'read', + }) + }) + + it('allows a personal API key regardless of workspaceId on the auth result', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'personal', + workspaceId: 'ws-B', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toBeUndefined() + expect(result.workflow).toBeDefined() + }) + + it('allows session auth (no apiKeyType) when workspace permission grants access', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'session', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toBeUndefined() + expect(result.workflow).toBeDefined() + }) + + it('still enforces workspace-permission rejection for personal keys', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'personal', + }) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: false, + status: 403, + message: 'Access denied', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toEqual({ message: 'Access denied', status: 403 }) + }) +}) diff --git a/apps/sim/app/api/workflows/middleware.ts b/apps/sim/app/api/workflows/middleware.ts index 2a66a616c77..10fa3017727 100644 --- a/apps/sim/app/api/workflows/middleware.ts +++ b/apps/sim/app/api/workflows/middleware.ts @@ -54,6 +54,15 @@ export async function validateWorkflowAccess( } } + if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) { + return { + error: { + message: 'API key is not authorized for this workspace', + status: 403, + }, + } + } + const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, userId: auth.userId, diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 03ec64d0a66..4915f2fd712 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -143,7 +143,7 @@ export const oauthAuthorizeParamsResponseSchema = z.object({ response_type: z.literal('code'), }) -const SHOPIFY_SHOP_DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/ +const SHOPIFY_SHOP_DOMAIN_REGEX = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\.myshopify\.com$/ export const shopifyShopDomainSchema = z.string().regex(SHOPIFY_SHOP_DOMAIN_REGEX) export const listOAuthConnectionsContract = defineRouteContract({