mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
############################################################################################################
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
############################################################################################################
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
############################################################################################################
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, any> = {
|
||||
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<string, any> = {}
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, any>): Record<string, any> {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new Error('Invalid OAuth2 token response: expected an object')
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
for (const key of ALLOWED_OAUTH2_TOKEN_FIELDS) {
|
||||
if (data[key] !== undefined) {
|
||||
result[key] = data[key]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user