Root cause
Authentication & access control introduced
feat(core): Introduce CredentialDependency entity to track credential dependencies (#27151)
How AI contributed: Direct introductionPublished Jul 22, 2026 in
n8n-io/n8nAll 6 findings in this repository
External Secrets permission bypass caused by parser mismatch: credential validation detected only tightly formatted $secrets expressions, while the runtime expression engine accepted whitespace/newline variants, allowing users without externalSecret:list to store references that later resolved to protected secret values.
Root cause
feat(core): Introduce CredentialDependency entity to track credential dependencies (#27151)
Fix
Detects $secrets references inside expression blocks despite whitespace or line breaks and passes the caller's externalSecret:list permission into resolver validation, preventing unauthorized secret resolution.
AI-assisted fix: Claude Code · n8n-assistant[bot]
Case facts
@@ -2,79 +2,22 @@ import type { User } from '@n8n/db'; import get from 'lodash/get'; import { type ICredentialDataDecryptedObject } from 'n8n-workflow'; +import {+ extractProviderKeysFromExpression,+ getExternalSecretExpressionPaths,+} from './external-secrets.utils';+ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; import { userHasScopes } from '@/permissions.ee/check-access';-import { getAllKeyPaths } from '@/utils'; // #region External Secrets -/**- * Regular expression pattern for valid provider keys.- * Keep this in sync with the regex implemented in CreateSecretsProviderConnectionDto.- */-const PROVIDER_KEY_PATTERN = '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*';--/**- * Checks if a string value contains an external secret expression.- * Detects both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']).- */-export function containsExternalSecretExpression(value: string): boolean {- const containsExpression = value.includes('{{') && value.includes('}}');- if (!containsExpression) {- return false;- }- return value.includes('$secrets.') || value.includes('$secrets[');-}--/**- * Extracts the provider keys from an expression string.- * Supports both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']).- * Only extracts provider keys from $secrets references inside {{ }} expression braces.- *- * @param expression - The expression string containing $secrets reference- * @returns Array of unique provider keys, or empty array if none found- *- * @example- * extractProviderKeys("={{ $secrets.vault.myKey }}") // returns ["vault"]- * extractProviderKeys("={{ $secrets['aws']['secret'] }}") // returns ["aws"]- * extractProviderKeys("={{ $secrets.vault.myKey + ':' + $secrets.aws.otherKey }}") // returns ["vault", "aws"]- * extractProviderKeys("$secrets.vault.key") // returns [] (not inside braces)- */-export function extractProviderKeys(expression: string): string[] {- const providerKeys = new Set<string>();-- const expressionBlocks = expression.matchAll(/\{\{(.*?)\}\}/gs);-- for (const expression of expressionBlocks) {- const expressionContent = expression[1]; // Content inside {{ }}-- // Match all dot notation occurrences: $secrets.providerKey- const dotMatches = expressionContent.matchAll(- new RegExp(`\\$secrets\\.(${PROVIDER_KEY_PATTERN})`, 'g'),- );- for (const match of dotMatches) {- providerKeys.add(match[1]);- }-- // Match all bracket notation occurrences: $secrets['providerKey'] or $secrets["providerKey"]- const bracketMatches = expressionContent.matchAll(- new RegExp(`\\$secrets\\[['"](${PROVIDER_KEY_PATTERN})['"]\\]`, 'g'),- );- for (const match of bracketMatches) {- providerKeys.add(match[1]);- }- }-- return Array.from(providerKeys);-}- /** * Checks if credential data contains any external secret expressions ($secrets) */ function containsExternalSecrets(data: ICredentialDataDecryptedObject): boolean {- const secretPaths = getAllKeyPaths(data, '', [], containsExternalSecretExpression);- return secretPaths.length > 0;+ return getExternalSecretExpressionPaths(data).length > 0; } /**@@ -85,7 +28,7 @@ export function isChangingExternalSecretExpression( existingData: ICredentialDataDecryptedObject, ): boolean { // Find all paths in newData that contain external secret expressions@@ -0,0 +1,76 @@+import get from 'lodash/get';+import type { ICredentialDataDecryptedObject } from 'n8n-workflow';++import { getAllKeyPaths } from '@/utils';++/**+ * Regular expression pattern for valid provider keys.+ * Keep this in sync with the regex implemented in CreateSecretsProviderConnectionDto.+ */+const PROVIDER_KEY_PATTERN = '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*';++/**+ * Checks if a string value contains an external secret expression.+ * Detects both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']).+ */+function containsExternalSecretExpression(value: string): boolean {+ const containsExpression = value.includes('{{') && value.includes('}}');+ if (!containsExpression) {+ return false;+ }+ return value.includes('$secrets.') || value.includes('$secrets[');+}++export function getExternalSecretExpressionPaths(data: unknown): string[] {+ return getAllKeyPaths(data, '', [], containsExternalSecretExpression);+}++/**+ * Extracts the provider keys from an expression string.+ * Supports both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']).+ * Only extracts provider keys from $secrets references inside {{ }} expression braces.+ */+export function extractProviderKeysFromExpression(expression: string): string[] {+ const providerKeys = new Set<string>();++ const expressionBlocks = expression.matchAll(/\{\{(.*?)\}\}/gs);++ for (const expression of expressionBlocks) {+ const expressionContent = expression[1]; // Content inside {{ }}++ // Match all dot notation occurrences: $secrets.providerKey+ const dotMatches = expressionContent.matchAll(+ new RegExp(`\\$secrets\\.(${PROVIDER_KEY_PATTERN})(?=\\.)`, 'g'),+ );+ for (const match of dotMatches) {+ providerKeys.add(match[1]);+ }++ // Match all bracket notation occurrences: $secrets['providerKey'] or $secrets["providerKey"]+ const bracketMatches = expressionContent.matchAll(+ new RegExp(`\\$secrets\\[['"](${PROVIDER_KEY_PATTERN})['"]\\]`, 'g'),+ );+ for (const match of bracketMatches) {+ providerKeys.add(match[1]);+ }+ }++ return Array.from(providerKeys);+}++export function extractProviderKeysFromCredentialData(+ data: ICredentialDataDecryptedObject,+): ReadonlySet<string> {+ const secretPaths = getExternalSecretExpressionPaths(data);+ const providerKeys = new Set<string>();++ for (const path of secretPaths) {+ const expressionString = get(data, path);+ if (typeof expressionString !== 'string') continue;+ for (const providerKey of extractProviderKeysFromExpression(expressionString)) {+ providerKeys.add(providerKey);+ }+ }++ return providerKeys;+}@@ -37,6 +37,16 @@ import { NodeHelpers, } from 'n8n-workflow'; +import {+ CredentialDependencyService,+ type CredentialDependencyFilter,+} from './credential-dependency.service';+import { CredentialsFinderService } from './credentials-finder.service';+import {+ validateAccessToReferencedSecretProviders,+ validateExternalSecretsPermissions,+} from './validation';+ import { CredentialTypes } from '@/credential-types'; import { createCredentialsFromCredentialsEntity, CredentialsHelper } from '@/credentials-helper'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';@@ -53,13 +63,6 @@ import { CredentialsTester } from '@/services/credentials-tester.service'; import { OwnershipService } from '@/services/ownership.service'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service';-import { getAllKeyPaths } from '@/utils';--import { CredentialsFinderService } from './credentials-finder.service';-import {- validateAccessToReferencedSecretProviders,- validateExternalSecretsPermissions,-} from './validation'; export type CredentialsGetSharedOptions = | { allowGlobalScope: true; globalScope: Scope }@@ -69,10 +72,21 @@ type CreateCredentialOptions = CreateCredentialDto & { isManaged: boolean; }; +type GetManyCredentialsOptions = {+ listQueryOptions: ListQuery.Options;+ includeGlobal: boolean;+ includeData: boolean;+ onlySharedWithMe: boolean;+ filters?: {+ dependency?: CredentialDependencyFilter;+ };+};+ @Service() export class CredentialsService { constructor( private readonly credentialsRepository: CredentialsRepository,+ private readonly credentialDependencyService: CredentialDependencyService, private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly ownershipService: OwnershipService, private readonly logger: Logger,@@ -93,9 +107,12 @@ export class CredentialsService { private async addGlobalCredentials( credentials: CredentialsEntity[], includeData: boolean,+ dependencyFilter?: CredentialDependencyFilter, ): Promise<CredentialsEntity[]> {- const globalCredentials =- await this.credentialsRepository.findAllGlobalCredentials(includeData);+ const globalCredentials = await this.credentialsRepository.findAllGlobalCredentials({+ includeData,+ filters: { dependency: dependencyFilter },+ }); // Merge and deduplicate based on credential ID const credentialIds = new Set(credentials.map((c) => c.id));@@ -107,23 +124,27 @@ export class CredentialsService { async getMany( user: User, options: {- listQueryOptions?: ListQuery.Options & { includeData?: boolean };+ listQueryOptions?: ListQuery.Options; includeScopes?: boolean; includeData: true; onlySharedWithMe?: boolean; includeGlobal?: boolean;- externalSecretsStore?: string;+ filters?: {+ externalSecretsStore?: string;+ }; }, ): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>>>; async getMany( user: User, options?: {- listQueryOptions?: ListQuery.Options & { includeData?: boolean };+ listQueryOptions?: ListQuery.Options; includeScopes?: boolean;@@ -4,16 +4,20 @@ import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; import { getAllKeyPaths } from '@/utils'; +const SECRETS_REFERENCE_REGEX = /\$secrets\b/;+ /**- * Checks if a string value contains an external secret expression.- * Detects both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']).+ * Checks if a string value references `$secrets` inside an expression block.+ *+ * Detects both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']) and considering that string can have whitespaces, line terminators, etc. */ function containsExternalSecretExpression(value: string): boolean {- const containsExpression = value.includes('{{') && value.includes('}}');- if (!containsExpression) {- return false;+ for (const [, blockContent] of value.matchAll(/\{\{(.*?)\}\}/gs)) {+ if (SECRETS_REFERENCE_REGEX.test(blockContent)) {+ return true;+ } }- return value.includes('$secrets.') || value.includes('$secrets[');+ return false; } export function getExternalSecretExpressionPaths(data: unknown): string[] {@@ -33,22 +37,27 @@ export function extractProviderKeysFromExpression(expression: string): string[] for (const expression of expressionBlocks) { const expressionContent = expression[1]; // Content inside {{ }} - // Match provider keys in dot notation, including mixed notation:+ // Match provider keys in dot notation, including mixed notation and+ // whitespace/line terminators between the identifier and the accessor: // - $secrets.providerKey.secret // - $secrets.providerKey['secret'] // - $secrets.providerKey["secret"]+ // - $secrets .providerKey['secret']+ // - $secrets\n.providerKey["secret"] const dotMatches = expressionContent.matchAll(- new RegExp(`\\$secrets\\.(${SECRETS_PROVIDER_KEY_PATTERN})(?=\\.|\\[)`, 'g'),+ new RegExp(`\\$secrets\\s*\\.\\s*(${SECRETS_PROVIDER_KEY_PATTERN})(?=\\s*\\.|\\s*\\[)`, 'g'), ); for (const match of dotMatches) { providerKeys.add(match[1]); } - // Match bracket notation occurrences where the provider key is bracket-accessed:+ // Match bracket notation occurrences where the provider key is a string+ // literal directly bracket-accessed (whitespace tolerant): // - $secrets['providerKey']+ // - $secrets ['providerKey'] // - $secrets["providerKey"] const bracketMatches = expressionContent.matchAll(- new RegExp(`\\$secrets\\[['"](${SECRETS_PROVIDER_KEY_PATTERN})['"]\\]`, 'g'),+ new RegExp(`\\$secrets\\s*\\[\\s*['"](${SECRETS_PROVIDER_KEY_PATTERN})['"]\\s*\\]`, 'g'), ); for (const match of bracketMatches) { providerKeys.add(match[1]);AI-assisted change 5f8e1bfbdbbbdd81b46202faaea09e412cf3b21445da909c80a22c8f6db52d42 · Fix 8bf92996b99961b326685d571f0cce122b41c6a5467c651400f78620f684fb67