From da8b251a9a4c59484ceaf6f71df7406aede7bef2 Mon Sep 17 00:00:00 2001 From: yau-wd Date: Tue, 28 Apr 2026 21:22:48 +0800 Subject: [PATCH] Fix FLOWISE 566 OAuth2 (#6276) * fix: flowise 556 oauth2 * fix(flowise-566): code review suggestions from Gemini * feat(oauth2Security.test.ts): add test script * feat(constants.ts): add Pipedream to default OAuth2 allowed domains --- docker/.env.example | 2 + docker/docker-compose-queue-prebuilt.yml | 4 + docker/docker-compose.yml | 2 + docker/worker/.env.example | 2 + docker/worker/docker-compose.yml | 2 + packages/server/.env.example | 2 + packages/server/src/commands/base.ts | 2 + packages/server/src/routes/oauth2/index.ts | 94 +++++-- packages/server/src/utils/constants.ts | 43 +++ .../server/src/utils/oauth2Security.test.ts | 249 ++++++++++++++++++ packages/server/src/utils/oauth2Security.ts | 90 +++++++ 11 files changed, 465 insertions(+), 27 deletions(-) create mode 100644 packages/server/src/utils/oauth2Security.test.ts create mode 100644 packages/server/src/utils/oauth2Security.ts diff --git a/docker/.env.example b/docker/.env.example index 68ee0a3ce..7c02bdfa4 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -198,6 +198,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # CUSTOM_MCP_SECURITY_CHECK=true # CUSTOM_MCP_PROTOCOL=sse #(stdio | sse) # TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses) +# OAUTH2_SECURITY_CHECK=true +# OAUTH2_ALLOWED_TOKEN_DOMAINS= #(comma-separated list of additional OAuth2 provider domains to allow, e.g. keycloak.mycompany.com,auth.custom-idp.com) ############################################################################################################ diff --git a/docker/docker-compose-queue-prebuilt.yml b/docker/docker-compose-queue-prebuilt.yml index 85f4e472d..101ea470b 100644 --- a/docker/docker-compose-queue-prebuilt.yml +++ b/docker/docker-compose-queue-prebuilt.yml @@ -158,6 +158,8 @@ services: - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} - TRUST_PROXY=${TRUST_PROXY} + - OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK} + - OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS} healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:${PORT:-3000}/api/v1/ping'] interval: 10s @@ -308,6 +310,8 @@ services: - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} - TRUST_PROXY=${TRUST_PROXY} + - OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK} + - OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS} healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:${WORKER_PORT:-5566}/healthz'] interval: 10s diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 32d1f74e1..6eec369eb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -143,6 +143,8 @@ services: - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} - TRUST_PROXY=${TRUST_PROXY} + - OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK} + - OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS} ports: - '${PORT}:${PORT}' healthcheck: diff --git a/docker/worker/.env.example b/docker/worker/.env.example index 28e47d14b..3f92375be 100644 --- a/docker/worker/.env.example +++ b/docker/worker/.env.example @@ -197,6 +197,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # CUSTOM_MCP_SECURITY_CHECK=true # CUSTOM_MCP_PROTOCOL=sse #(stdio | sse) # TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses) +# OAUTH2_SECURITY_CHECK=true +# OAUTH2_ALLOWED_TOKEN_DOMAINS= #(comma-separated list of additional OAuth2 provider domains to allow, e.g. keycloak.mycompany.com,auth.custom-idp.com) ############################################################################################################ diff --git a/docker/worker/docker-compose.yml b/docker/worker/docker-compose.yml index 60e3a5cdc..9bcfa672b 100644 --- a/docker/worker/docker-compose.yml +++ b/docker/worker/docker-compose.yml @@ -142,6 +142,8 @@ services: - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} - TRUST_PROXY=${TRUST_PROXY} + - OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK} + - OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS} ports: - '${WORKER_PORT}:${WORKER_PORT}' healthcheck: diff --git a/packages/server/.env.example b/packages/server/.env.example index 52cbcaa02..54b1d13ee 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -197,6 +197,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # CUSTOM_MCP_SECURITY_CHECK=true # CUSTOM_MCP_PROTOCOL=sse #(stdio | sse) # TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses) +# OAUTH2_SECURITY_CHECK=true +# OAUTH2_ALLOWED_TOKEN_DOMAINS= #(comma-separated list of additional OAuth2 provider domains to allow, e.g. keycloak.mycompany.com,auth.custom-idp.com) ############################################################################################################ diff --git a/packages/server/src/commands/base.ts b/packages/server/src/commands/base.ts index 7d10c2831..e0187bb9d 100644 --- a/packages/server/src/commands/base.ts +++ b/packages/server/src/commands/base.ts @@ -111,6 +111,8 @@ export abstract class BaseCommand extends Command { HTTP_SECURITY_CHECK: Flags.string(), PATH_TRAVERSAL_SAFETY: Flags.string(), TRUST_PROXY: Flags.string(), + OAUTH2_SECURITY_CHECK: Flags.string(), + OAUTH2_ALLOWED_TOKEN_DOMAINS: Flags.string(), // Auth APP_URL: Flags.string(), diff --git a/packages/server/src/routes/oauth2/index.ts b/packages/server/src/routes/oauth2/index.ts index 817e7d64c..92b68ac83 100644 --- a/packages/server/src/routes/oauth2/index.ts +++ b/packages/server/src/routes/oauth2/index.ts @@ -56,17 +56,18 @@ * - token_received_at: When token was received (ISO string) */ -import express from 'express' import axios from 'axios' -import { Request, Response, NextFunction } from 'express' -import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import express, { NextFunction, Request, Response } from 'express' +import { secureAxiosRequest } from 'flowise-components' +import { StatusCodes } from 'http-status-codes' import { Credential } from '../../database/entities/Credential' import { WorkspaceShared } from '../../enterprise/database/entities/EnterpriseEntities' import { getActiveWorkspaceIdForRequest } from '../../enterprise/utils/tenantRequestGuards' -import { decryptCredentialData, encryptCredentialData } from '../../utils' import { InternalFlowiseError } from '../../errors/internalFlowiseError' -import { StatusCodes } from 'http-status-codes' -import { generateSuccessPage, generateErrorPage } from './templates' +import { decryptCredentialData, encryptCredentialData } from '../../utils' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { extractOAuth2TokenFields, validateOAuth2Url } from '../../utils/oauth2Security' +import { generateErrorPage, generateSuccessPage } from './templates' const router = express.Router() @@ -126,6 +127,15 @@ router.post('/authorize/:credentialId', async (req: Request, res: Response, next }) } + try { + validateOAuth2Url(authorizationUrl) + } catch (err) { + return res.status(400).json({ + success: false, + message: err instanceof Error ? err.message : 'Invalid authorization URL' + }) + } + const defaultRedirectUri = `${req.protocol}://${req.get('host')}/api/v1/oauth2-credential/callback` const finalRedirectUri = redirect_uri || defaultRedirectUri @@ -236,6 +246,19 @@ router.get('/callback', async (req: Request, res: Response) => { return res.status(400).send(errorHtml) } + try { + validateOAuth2Url(tokenUrl) + } catch (err) { + const errorHtml = generateErrorPage( + 'Invalid token endpoint URL', + err instanceof Error ? err.message : 'Token endpoint URL is not allowed', + 'Please check your credential configuration.' + ) + + res.setHeader('Content-Type', 'text/html') + return res.status(400).send(errorHtml) + } + const defaultRedirectUri = `${req.protocol}://${req.get('host')}/api/v1/oauth2-credential/callback` const finalRedirectUri = redirect_uri || defaultRedirectUri @@ -251,28 +274,34 @@ router.get('/callback', async (req: Request, res: Response) => { tokenRequestData.scope = scope } - const tokenResponse = await axios.post(tokenUrl, new URLSearchParams(tokenRequestData).toString(), { + const tokenResponse = await secureAxiosRequest({ + method: 'POST', + url: tokenUrl, + data: new URLSearchParams(tokenRequestData).toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' } }) - const tokenData = tokenResponse.data + if (tokenResponse.status >= 400) { + const errorHtml = generateErrorPage( + tokenResponse.data?.error || 'token_exchange_failed', + tokenResponse.data?.error_description || 'Token exchange failed', + tokenResponse.data?.error_description ? `Description: ${tokenResponse.data.error_description}` : undefined + ) + res.setHeader('Content-Type', 'text/html') + return res.status(tokenResponse.status).send(errorHtml) + } + + const tokenData = extractOAuth2TokenFields(tokenResponse.data) - // Update the credential data with token information const updatedCredentialData: any = { ...decryptedData, ...tokenData, token_received_at: new Date().toISOString() } - // Add refresh token if provided - if (tokenData.refresh_token) { - updatedCredentialData.refresh_token = tokenData.refresh_token - } - - // Calculate token expiry time if (tokenData.expires_in) { const expiryTime = new Date(Date.now() + tokenData.expires_in * 1000) updatedCredentialData.expires_at = expiryTime.toISOString() @@ -355,6 +384,15 @@ router.post('/refresh/:credentialId', async (req: Request, res: Response, next: }) } + try { + validateOAuth2Url(tokenUrl) + } catch (err) { + return res.status(400).json({ + success: false, + message: err instanceof Error ? err.message : 'Token endpoint URL is not allowed' + }) + } + const refreshRequestData: any = { client_id: clientId, client_secret: clientSecret, @@ -366,29 +404,32 @@ router.post('/refresh/:credentialId', async (req: Request, res: Response, next: refreshRequestData.scope = scope } - const tokenResponse = await axios.post(tokenUrl, new URLSearchParams(refreshRequestData).toString(), { + const tokenResponse = await secureAxiosRequest({ + method: 'POST', + url: tokenUrl, + data: new URLSearchParams(refreshRequestData).toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' } }) - // Extract token data from response - const tokenData = tokenResponse.data + if (tokenResponse.status >= 400) { + return res.status(tokenResponse.status).json({ + success: false, + message: `Token refresh failed: ${tokenResponse.data?.error_description || tokenResponse.statusText}`, + details: tokenResponse.data + }) + } + + const tokenData = extractOAuth2TokenFields(tokenResponse.data) - // Update the credential data with new token information const updatedCredentialData: any = { ...decryptedData, ...tokenData, token_received_at: new Date().toISOString() } - // Update refresh token if a new one was provided - if (tokenData.refresh_token) { - updatedCredentialData.refresh_token = tokenData.refresh_token - } - - // Calculate token expiry time if (tokenData.expires_in) { const expiryTime = new Date(Date.now() + tokenData.expires_in * 1000) updatedCredentialData.expires_at = expiryTime.toISOString() @@ -403,13 +444,12 @@ router.post('/refresh/:credentialId', async (req: Request, res: Response, next: updatedDate: new Date() }) - // Return success response res.json({ success: true, message: 'OAuth2 token refreshed successfully', credentialId: credential.id, tokenInfo: { - ...tokenData, + token_type: tokenData.token_type, has_new_refresh_token: !!tokenData.refresh_token, expires_at: updatedCredentialData.expires_at } diff --git a/packages/server/src/utils/constants.ts b/packages/server/src/utils/constants.ts index a2de53c5f..900a716b4 100644 --- a/packages/server/src/utils/constants.ts +++ b/packages/server/src/utils/constants.ts @@ -108,6 +108,49 @@ export const INPUT_PARAMS_TYPE = [ 'tabs' ] +export const ALLOWED_OAUTH2_TOKEN_FIELDS = [ + 'access_token', + 'refresh_token', + 'token_type', + 'expires_in', + 'scope', + 'id_token', + 'granted_scope' +] + +export const DEFAULT_ALLOWED_OAUTH2_DOMAINS = [ + 'login.microsoftonline.com', + 'oauth2.googleapis.com', + 'accounts.google.com', + 'github.com', + 'login.salesforce.com', + 'test.salesforce.com', + 'oauth2.hubapi.com', + 'api.hubapi.com', + 'oauth.pipedrive.com', + 'app.clickup.com', + 'api.clickup.com', + 'login.xero.com', + 'identity.xero.com', + 'oauth2.sky.blackbaud.com', + 'app.asana.com', + 'todoist.com', + 'api.todoist.com', + 'slack.com', + 'oauth.pocketsmith.com', + 'api.notion.com', + 'api.dropboxapi.com', + 'api.box.com', + 'zoom.us', + 'auth.atlassian.com', + 'login.zoho.com', + 'accounts.zoho.com', + 'airtable.com', + 'api.linear.app', + 'discord.com', + 'api.pipedream.com' +] + export const LICENSE_QUOTAS = { // Renew per month PREDICTIONS_LIMIT: 'quota:predictions', diff --git a/packages/server/src/utils/oauth2Security.test.ts b/packages/server/src/utils/oauth2Security.test.ts new file mode 100644 index 000000000..b331f4981 --- /dev/null +++ b/packages/server/src/utils/oauth2Security.test.ts @@ -0,0 +1,249 @@ +// Mock the constants module to avoid pulling in the full application dependency graph +jest.mock('./constants', () => ({ + ALLOWED_OAUTH2_TOKEN_FIELDS: ['access_token', 'refresh_token', 'token_type', 'expires_in', 'scope', 'id_token', 'granted_scope'], + DEFAULT_ALLOWED_OAUTH2_DOMAINS: [ + 'login.microsoftonline.com', + 'oauth2.googleapis.com', + 'accounts.google.com', + 'github.com', + 'login.salesforce.com', + 'test.salesforce.com', + 'oauth2.hubapi.com', + 'api.hubapi.com', + 'oauth.pipedrive.com', + 'app.clickup.com', + 'api.clickup.com', + 'login.xero.com', + 'identity.xero.com', + 'oauth2.sky.blackbaud.com', + 'app.asana.com', + 'todoist.com', + 'api.todoist.com', + 'slack.com', + 'oauth.pocketsmith.com', + 'api.notion.com', + 'api.dropboxapi.com', + 'api.box.com', + 'zoom.us', + 'auth.atlassian.com', + 'login.zoho.com', + 'accounts.zoho.com', + 'airtable.com', + 'api.linear.app', + 'discord.com' + ] +})) + +import { getOAuth2AllowedDomains, validateOAuth2Url, extractOAuth2TokenFields } from './oauth2Security' +import { ALLOWED_OAUTH2_TOKEN_FIELDS, DEFAULT_ALLOWED_OAUTH2_DOMAINS } from './constants' + +const originalEnv = process.env + +beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.OAUTH2_SECURITY_CHECK + delete process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS +}) + +afterAll(() => { + process.env = originalEnv +}) + +// --------------------------------------------------------------------------- +// getOAuth2AllowedDomains +// --------------------------------------------------------------------------- +describe('getOAuth2AllowedDomains', () => { + it('returns default domains when security check is enabled and no custom domains', () => { + const domains = getOAuth2AllowedDomains() + expect(domains).toEqual(expect.arrayContaining(DEFAULT_ALLOWED_OAUTH2_DOMAINS)) + expect(domains.length).toBe(DEFAULT_ALLOWED_OAUTH2_DOMAINS.length) + }) + + it('merges custom domains with defaults when security check is enabled', () => { + process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'custom.example.com, another.dev' + const domains = getOAuth2AllowedDomains() + expect(domains).toContain('custom.example.com') + expect(domains).toContain('another.dev') + for (const d of DEFAULT_ALLOWED_OAUTH2_DOMAINS) { + expect(domains).toContain(d) + } + }) + + it('deduplicates domains that overlap with defaults', () => { + process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'github.com' + const domains = getOAuth2AllowedDomains() + const githubCount = domains.filter((d) => d === 'github.com').length + expect(githubCount).toBe(1) + }) + + it('returns only custom domains when security check is disabled', () => { + process.env.OAUTH2_SECURITY_CHECK = 'false' + process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'my.corp.dev' + const domains = getOAuth2AllowedDomains() + expect(domains).toEqual(['my.corp.dev']) + }) + + it('returns empty array when security check is disabled and no custom domains', () => { + process.env.OAUTH2_SECURITY_CHECK = 'false' + const domains = getOAuth2AllowedDomains() + expect(domains).toEqual([]) + }) + + it('trims whitespace and lowercases custom domains', () => { + process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = ' MyDomain.COM , UPPER.IO ' + const domains = getOAuth2AllowedDomains() + expect(domains).toContain('mydomain.com') + expect(domains).toContain('upper.io') + }) + + it('ignores empty segments from trailing/leading commas', () => { + process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = ',,,valid.com,,,' + const domains = getOAuth2AllowedDomains() + expect(domains).toContain('valid.com') + expect(domains.every((d) => d.length > 0)).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// validateOAuth2Url +// --------------------------------------------------------------------------- +describe('validateOAuth2Url', () => { + describe('with security check enabled (default)', () => { + it('accepts HTTPS URLs on default allowed domains', () => { + expect(() => validateOAuth2Url('https://login.microsoftonline.com/token')).not.toThrow() + expect(() => validateOAuth2Url('https://oauth2.googleapis.com/token')).not.toThrow() + expect(() => validateOAuth2Url('https://github.com/login/oauth/access_token')).not.toThrow() + }) + + it('accepts subdomains of allowed domains', () => { + expect(() => validateOAuth2Url('https://accounts.zoom.us/oauth/token')).not.toThrow() + expect(() => validateOAuth2Url('https://sub.login.microsoftonline.com/token')).not.toThrow() + }) + + it('rejects HTTP URLs', () => { + expect(() => validateOAuth2Url('http://login.microsoftonline.com/token')).toThrow('OAuth2 URL must use HTTPS') + }) + + it('rejects domains not in the allowed list', () => { + expect(() => validateOAuth2Url('https://evil.example.com/token')).toThrow('not in the allowed list') + }) + + it('rejects domain that only partially matches (no dot boundary)', () => { + // "notgithub.com" should NOT match "github.com" + expect(() => validateOAuth2Url('https://notgithub.com/token')).toThrow('not in the allowed list') + }) + + it('accepts custom domains added via env var', () => { + process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'custom-idp.example.com' + expect(() => validateOAuth2Url('https://custom-idp.example.com/token')).not.toThrow() + }) + + it('throws on invalid URL', () => { + expect(() => validateOAuth2Url('not-a-url')).toThrow() + }) + }) + + describe('with security check disabled', () => { + beforeEach(() => { + process.env.OAUTH2_SECURITY_CHECK = 'false' + }) + + it('skips validation entirely when no custom domains are configured', () => { + expect(() => validateOAuth2Url('http://anything.local:8080/token')).not.toThrow() + }) + + it('allows HTTP when custom domains are configured', () => { + process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'local.dev' + expect(() => validateOAuth2Url('http://local.dev/token')).not.toThrow() + }) + + it('still rejects domains not matching custom list when custom domains exist', () => { + process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'local.dev' + expect(() => validateOAuth2Url('http://other.dev/token')).toThrow('not in the allowed list') + }) + + it('supports subdomain matching against custom domains', () => { + process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS = 'myidp.local' + expect(() => validateOAuth2Url('http://auth.myidp.local/token')).not.toThrow() + }) + }) +}) + +// --------------------------------------------------------------------------- +// extractOAuth2TokenFields +// --------------------------------------------------------------------------- +describe('extractOAuth2TokenFields', () => { + it('extracts all recognised fields from a complete token response', () => { + const data: Record = { + access_token: 'abc123', + refresh_token: 'ref456', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile', + id_token: 'jwt.token.here', + granted_scope: 'openid' + } + const result = extractOAuth2TokenFields(data) + expect(result).toEqual(data) + }) + + it('omits fields not in the allowed list', () => { + const data = { + access_token: 'abc', + malicious_field: 'should be removed', + secret_key: '12345' + } + const result = extractOAuth2TokenFields(data) + expect(result).toEqual({ access_token: 'abc' }) + expect(result).not.toHaveProperty('malicious_field') + expect(result).not.toHaveProperty('secret_key') + }) + + it('returns empty object when no recognised fields are present', () => { + const result = extractOAuth2TokenFields({ foo: 'bar', baz: 42 }) + expect(result).toEqual({}) + }) + + it('preserves undefined-valued allowed fields as absent (not included)', () => { + const result = extractOAuth2TokenFields({ access_token: undefined }) + expect(result).not.toHaveProperty('access_token') + }) + + it('preserves null-valued allowed fields', () => { + const result = extractOAuth2TokenFields({ access_token: null }) + expect(result).toEqual({ access_token: null }) + }) + + it('only includes fields from ALLOWED_OAUTH2_TOKEN_FIELDS constant', () => { + const allFieldData: Record = {} + for (const field of ALLOWED_OAUTH2_TOKEN_FIELDS) { + allFieldData[field] = `value_${field}` + } + allFieldData['extra'] = 'nope' + + const result = extractOAuth2TokenFields(allFieldData) + expect(Object.keys(result).sort()).toEqual([...ALLOWED_OAUTH2_TOKEN_FIELDS].sort()) + }) + + describe('throws on invalid input', () => { + it('throws on null', () => { + expect(() => extractOAuth2TokenFields(null as any)).toThrow('expected an object') + }) + + it('throws on undefined', () => { + expect(() => extractOAuth2TokenFields(undefined as any)).toThrow('expected an object') + }) + + it('throws on array', () => { + expect(() => extractOAuth2TokenFields([] as any)).toThrow('expected an object') + }) + + it('throws on string', () => { + expect(() => extractOAuth2TokenFields('string' as any)).toThrow('expected an object') + }) + + it('throws on number', () => { + expect(() => extractOAuth2TokenFields(42 as any)).toThrow('expected an object') + }) + }) +}) diff --git a/packages/server/src/utils/oauth2Security.ts b/packages/server/src/utils/oauth2Security.ts new file mode 100644 index 000000000..a8717f8ae --- /dev/null +++ b/packages/server/src/utils/oauth2Security.ts @@ -0,0 +1,90 @@ +import { ALLOWED_OAUTH2_TOKEN_FIELDS, DEFAULT_ALLOWED_OAUTH2_DOMAINS } from './constants' + +function getCustomDomains(): string[] { + return (process.env.OAUTH2_ALLOWED_TOKEN_DOMAINS ?? '') + .split(',') + .map((d) => d.trim().toLowerCase()) + .filter(Boolean) +} + +/** + * Builds the effective list of allowed OAuth2 token-endpoint domains. + * + * When `OAUTH2_SECURITY_CHECK` is enabled (default), the returned list is the union + * of `DEFAULT_ALLOWED_OAUTH2_DOMAINS` and any extra domains specified in the + * `OAUTH2_ALLOWED_TOKEN_DOMAINS` env var. When disabled (`OAUTH2_SECURITY_CHECK=false`), + * only the custom env var entries are used (empty list if none are configured). + * + * @returns Array of lowercase domain strings that OAuth2 URLs are permitted to contact + */ +export function getOAuth2AllowedDomains(): string[] { + const securityCheckEnabled = process.env.OAUTH2_SECURITY_CHECK !== 'false' + const customDomains = getCustomDomains() + if (securityCheckEnabled) { + return [...new Set([...DEFAULT_ALLOWED_OAUTH2_DOMAINS, ...customDomains])] + } + return customDomains +} + +/** + * Validates an OAuth2 URL against the allowed domain list. + * + * This is a defence-in-depth measure against SSRF: even if the caller also uses + * `secureAxiosRequest` (which blocks private/internal IPs), this function ensures + * the server only contacts known, legitimate OAuth2 provider domains. + * + * Subdomain matching is supported — e.g. allowing `zoom.us` also permits + * `accounts.zoom.us`. When `OAUTH2_SECURITY_CHECK=false` and no custom domains + * are configured, validation is skipped entirely. + * + * HTTPS is enforced only when `OAUTH2_SECURITY_CHECK` is enabled (the default). + * Setting `OAUTH2_SECURITY_CHECK=false` allows HTTP, which is intended for local + * development and testing against non-TLS OAuth2 servers. + * + * @param url - The OAuth2 authorization or token endpoint URL to validate + * @throws Error if the URL's domain is not in the allowed list, or if HTTPS is + * required (`OAUTH2_SECURITY_CHECK` enabled) and the URL uses a non-HTTPS protocol + */ +export function validateOAuth2Url(url: string): void { + const securityCheckEnabled = process.env.OAUTH2_SECURITY_CHECK !== 'false' + const allowedDomains = getOAuth2AllowedDomains() + + if (!securityCheckEnabled && allowedDomains.length === 0) return + + const parsed = new URL(url) + + if (securityCheckEnabled && parsed.protocol !== 'https:') { + throw new Error(`OAuth2 URL must use HTTPS: ${url}`) + } + + const hostname = parsed.hostname.toLowerCase() + const isAllowed = allowedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)) + if (!isAllowed) { + throw new Error(`OAuth2 URL domain "${hostname}" is not in the allowed list.`) + } +} + +/** + * Extracts only recognised OAuth2 token fields from an arbitrary response object. + * + * Prevents data exfiltration by discarding any unexpected keys that a malicious or + * misconfigured token endpoint might return. Only fields listed in + * `ALLOWED_OAUTH2_TOKEN_FIELDS` are copied to the result. + * + * @param data - The raw JSON response body from the OAuth2 token endpoint + * @returns A new object containing only the allowed OAuth2 token fields + * @throws Error if the data is not a valid object (null, undefined, array, or primitive type) + */ +export function extractOAuth2TokenFields(data: Record): Record { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('Invalid OAuth2 token response: expected an object') + } + + const result: Record = {} + for (const key of ALLOWED_OAUTH2_TOKEN_FIELDS) { + if (data[key] !== undefined) { + result[key] = data[key] + } + } + return result +}