mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
feat(core): Improve custom auth JSON masking, preserve shape, redact leaf values (#27675)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
72b41a9a76
commit
d36cfa1624
@@ -13,8 +13,10 @@ import { CREDENTIAL_ERRORS, CredentialDataError, Credentials, type ErrorReporter
|
||||
import {
|
||||
CREDENTIAL_BLANKING_VALUE,
|
||||
CREDENTIAL_EMPTY_VALUE,
|
||||
jsonParse,
|
||||
type ICredentialDataDecryptedObject,
|
||||
type ICredentialType,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { mockExistingCredential } from './credentials.test-data';
|
||||
@@ -485,6 +487,11 @@ describe('CredentialsService', () => {
|
||||
|
||||
const httpCustomAuthCredType = {
|
||||
properties: [
|
||||
{
|
||||
name: 'json',
|
||||
type: 'json',
|
||||
typeOptions: { redactJsonLeaves: true },
|
||||
},
|
||||
{
|
||||
name: 'otherField',
|
||||
type: 'string',
|
||||
@@ -499,7 +506,7 @@ describe('CredentialsService', () => {
|
||||
const redactedData = service.redact(decryptedData, credential);
|
||||
|
||||
expect(redactedData).toEqual({
|
||||
json: CREDENTIAL_BLANKING_VALUE,
|
||||
json: JSON.stringify({ key: '***' }, null, 2),
|
||||
otherField: 'not-redacted',
|
||||
});
|
||||
});
|
||||
@@ -518,6 +525,11 @@ describe('CredentialsService', () => {
|
||||
|
||||
const httpCustomAuthCredType = {
|
||||
properties: [
|
||||
{
|
||||
name: 'json',
|
||||
type: 'json',
|
||||
typeOptions: { redactJsonLeaves: true },
|
||||
},
|
||||
{
|
||||
name: 'otherField',
|
||||
type: 'string',
|
||||
@@ -537,6 +549,156 @@ describe('CredentialsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('httpCustomAuth redactJsonLeaves (via service.redact)', () => {
|
||||
const makeHttpCustomAuthCredential = () =>
|
||||
mock<CredentialsEntity>({ id: '123', name: 'Test', type: 'httpCustomAuth' });
|
||||
|
||||
const makeCredType = () =>
|
||||
({
|
||||
properties: [{ name: 'json', type: 'json', typeOptions: { redactJsonLeaves: true } }],
|
||||
}) as unknown as ICredentialType;
|
||||
|
||||
it('should redact string leaf values', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact(
|
||||
{ json: '{"token": "secret", "key": "abc"}' },
|
||||
makeHttpCustomAuthCredential(),
|
||||
);
|
||||
|
||||
expect(result.json).toEqual(JSON.stringify({ token: '***', key: '***' }, null, 2));
|
||||
});
|
||||
|
||||
it('should redact numeric leaf values', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact(
|
||||
{ json: '{"port": 8080, "timeout": 30}' },
|
||||
makeHttpCustomAuthCredential(),
|
||||
);
|
||||
|
||||
expect(result.json).toEqual(JSON.stringify({ port: '***', timeout: '***' }, null, 2));
|
||||
});
|
||||
|
||||
it('should redact boolean leaf values', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact(
|
||||
{ json: '{"enabled": true, "debug": false}' },
|
||||
makeHttpCustomAuthCredential(),
|
||||
);
|
||||
|
||||
expect(result.json).toEqual(JSON.stringify({ enabled: '***', debug: '***' }, null, 2));
|
||||
});
|
||||
|
||||
it('should redact leaves of nested objects recursively', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact(
|
||||
{
|
||||
json: '{"headers": {"Authorization": "Bearer token", "X-Api-Key": "key"}, "url": "https://example.com"}',
|
||||
},
|
||||
makeHttpCustomAuthCredential(),
|
||||
);
|
||||
|
||||
expect(result.json).toEqual(
|
||||
JSON.stringify(
|
||||
{ headers: { Authorization: '***', 'X-Api-Key': '***' }, url: '***' },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should redact leaves inside arrays', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact(
|
||||
{ json: '{"scopes": ["read", "write"], "tags": ["prod"]}' },
|
||||
makeHttpCustomAuthCredential(),
|
||||
);
|
||||
|
||||
expect(result.json).toEqual(
|
||||
JSON.stringify({ scopes: ['***', '***'], tags: ['***'] }, null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it('should redact leaves in deeply mixed nesting (array of objects)', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact(
|
||||
{ json: '{"rules": [{"key": "a", "val": 1}, {"key": "b", "val": 2}]}' },
|
||||
makeHttpCustomAuthCredential(),
|
||||
);
|
||||
|
||||
expect(result.json).toEqual(
|
||||
JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{ key: '***', val: '***' },
|
||||
{ key: '***', val: '***' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return json unchanged when it is not parseable', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact({ json: 'not-valid-json' }, makeHttpCustomAuthCredential());
|
||||
|
||||
expect(result.json).toEqual('not-valid-json');
|
||||
});
|
||||
|
||||
it('should redact leaves in a deeply nested object', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact(
|
||||
{ json: '{"a":{"b":{"c":{"d":"deep-secret"}}}}' },
|
||||
makeHttpCustomAuthCredential(),
|
||||
);
|
||||
|
||||
expect(result.json).toEqual(JSON.stringify({ a: { b: { c: { d: '***' } } } }, null, 2));
|
||||
});
|
||||
|
||||
it('should redact leaves inside an array nested inside a deep object', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact(
|
||||
{
|
||||
json: '{"auth":{"headers":[{"name":"Authorization","value":"Bearer s"},{"name":"X-Key","value":"abc"}]}}',
|
||||
},
|
||||
makeHttpCustomAuthCredential(),
|
||||
);
|
||||
|
||||
expect(result.json).toEqual(
|
||||
JSON.stringify(
|
||||
{
|
||||
auth: {
|
||||
headers: [
|
||||
{ name: '***', value: '***' },
|
||||
{ name: '***', value: '***' },
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should redact null leaf values', () => {
|
||||
credentialTypes.getByName.calledWith('httpCustomAuth').mockReturnValueOnce(makeCredType());
|
||||
|
||||
const result = service.redact({ json: '{"token":null}' }, makeHttpCustomAuthCredential());
|
||||
|
||||
expect(result.json).toEqual(JSON.stringify({ token: '***' }, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
it('should not redact json field for non-httpCustomAuth credential types', () => {
|
||||
const credential = mock<CredentialsEntity>({
|
||||
id: '123',
|
||||
@@ -2064,6 +2226,182 @@ describe('CredentialsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('unredact (httpCustomAuth JSON shape merging)', () => {
|
||||
const s = (v: unknown) => JSON.stringify(v);
|
||||
const p = (v: unknown) => jsonParse(String(v));
|
||||
const jsonLeafProps = [
|
||||
{ name: 'json', type: 'json', typeOptions: { redactJsonLeaves: true } },
|
||||
] as unknown as INodeProperties[];
|
||||
|
||||
it('should restore all *** leaves from saved JSON when all are redacted', () => {
|
||||
const saved = { token: 'real-token', key: 'real-key' };
|
||||
const result = service.unredact(
|
||||
{ json: s({ token: '***', key: '***' }) },
|
||||
{ json: s(saved) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual(saved);
|
||||
});
|
||||
|
||||
it('should keep new value for changed leaf and restore *** leaf from saved', () => {
|
||||
const result = service.unredact(
|
||||
{ json: s({ token: 'new-token', key: '***' }) },
|
||||
{ json: s({ token: 'old-token', key: 'old-key' }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({ token: 'new-token', key: 'old-key' });
|
||||
});
|
||||
|
||||
it('should merge *** leaves recursively in nested objects', () => {
|
||||
const result = service.unredact(
|
||||
{ json: s({ headers: { Authorization: 'Bearer new', 'X-Api-Key': '***' } }) },
|
||||
{ json: s({ headers: { Authorization: 'Bearer secret', 'X-Api-Key': 'key123' } }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({
|
||||
headers: { Authorization: 'Bearer new', 'X-Api-Key': 'key123' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge *** elements in arrays by position', () => {
|
||||
const result = service.unredact(
|
||||
{ json: s({ scopes: ['***', 'new-write', '***'] }) },
|
||||
{ json: s({ scopes: ['read', 'write', 'admin'] }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({ scopes: ['read', 'new-write', 'admin'] });
|
||||
});
|
||||
|
||||
it('should save all leaves as-is when none are redacted', () => {
|
||||
const result = service.unredact(
|
||||
{ json: s({ token: 'completely-new', extra: 'new-field' }) },
|
||||
{ json: s({ token: 'old-token' }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({ token: 'completely-new', extra: 'new-field' });
|
||||
});
|
||||
|
||||
it('should leave json unchanged when it is not valid JSON', () => {
|
||||
const result = service.unredact(
|
||||
{ json: 'not-valid-json' },
|
||||
{ json: s({ token: 'saved' }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(result.json).toEqual('not-valid-json');
|
||||
});
|
||||
|
||||
it('should not apply JSON leaf merge when no props are given', () => {
|
||||
const result = service.unredact(
|
||||
{ json: s({ token: '***' }) },
|
||||
{ json: s({ token: 'saved' }) },
|
||||
);
|
||||
// *** is not a standard sentinel so it stays as-is
|
||||
expect(p(result.json)).toEqual({ token: '***' });
|
||||
});
|
||||
|
||||
it('should not apply JSON leaf merge when props have no redactJsonLeaves field', () => {
|
||||
const result = service.unredact(
|
||||
{ json: s({ token: '***' }) },
|
||||
{ json: s({ token: 'saved' }) },
|
||||
[{ name: 'json', type: 'json' }] as INodeProperties[],
|
||||
);
|
||||
expect(p(result.json)).toEqual({ token: '***' });
|
||||
});
|
||||
|
||||
it('should restore *** leaves in a deeply nested object', () => {
|
||||
const result = service.unredact(
|
||||
{ json: s({ a: { b: { c: { d: '***' } } } }) },
|
||||
{ json: s({ a: { b: { c: { d: 'deep-secret' } } } }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({ a: { b: { c: { d: 'deep-secret' } } } });
|
||||
});
|
||||
|
||||
it('should restore *** leaves inside arrays nested inside deep objects', () => {
|
||||
const result = service.unredact(
|
||||
{
|
||||
json: s({
|
||||
auth: {
|
||||
headers: [
|
||||
{ name: '***', value: '***' },
|
||||
{ name: '***', value: 'new-value' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
json: s({
|
||||
auth: {
|
||||
headers: [
|
||||
{ name: 'Authorization', value: 'Bearer secret' },
|
||||
{ name: 'X-Api-Key', value: 'key123' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({
|
||||
auth: {
|
||||
headers: [
|
||||
{ name: 'Authorization', value: 'Bearer secret' },
|
||||
{ name: 'X-Api-Key', value: 'new-value' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should drop a key the user removed from the JSON', () => {
|
||||
// When the user removes a key, it simply won't be in newVal — it is not re-added from saved
|
||||
const result = service.unredact(
|
||||
{ json: s({ token: '***' }) },
|
||||
{ json: s({ token: 'secret', removedKey: 'data' }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({ token: 'secret' });
|
||||
expect(p(result.json)).not.toHaveProperty('removedKey');
|
||||
});
|
||||
|
||||
it('should save a new key the user added to the JSON', () => {
|
||||
const result = service.unredact(
|
||||
{ json: s({ token: '***', newHeader: 'X-Custom-Value' }) },
|
||||
{ json: s({ token: 'secret' }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({ token: 'secret', newHeader: 'X-Custom-Value' });
|
||||
});
|
||||
|
||||
it('should use the new scalar value when the user changes a key from object to scalar', () => {
|
||||
// Type change: saved was an object, user replaced it with a plain string — new value wins
|
||||
const result = service.unredact(
|
||||
{ json: s({ auth: 'new-plain-string' }) },
|
||||
{ json: s({ auth: { token: 'secret', key: 'abc' } }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({ auth: 'new-plain-string' });
|
||||
});
|
||||
|
||||
it('should preserve the new array when the user changes a key from object to array', () => {
|
||||
// Type mismatch (array vs object): user made a structural change — new value is preserved as-is
|
||||
const result = service.unredact(
|
||||
{ json: s({ auth: ['new-item-1', 'new-item-2'] }) },
|
||||
{ json: s({ auth: { token: 'secret' } }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({ auth: ['new-item-1', 'new-item-2'] });
|
||||
});
|
||||
|
||||
it('should preserve the new object when the user changes a key from array to object (even with *** sentinels)', () => {
|
||||
// Type mismatch (object vs array): structural change — new object is preserved as-is, *** unresolved
|
||||
const result = service.unredact(
|
||||
{ json: s({ data: { key: '***' } }) },
|
||||
{ json: s({ data: ['secret1', 'secret2'] }) },
|
||||
jsonLeafProps,
|
||||
);
|
||||
expect(p(result.json)).toEqual({ data: { key: '***' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkCredentialData', () => {
|
||||
const testProjectId = 'test-project-id';
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ export class CredentialsController {
|
||||
mergedCredentials.data = this.credentialsService.unredact(
|
||||
mergedCredentials.data,
|
||||
decryptedData,
|
||||
this.credentialsService.getCredentialTypeProperties(storedCredential.type),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,19 +34,11 @@ import {
|
||||
displayParameter,
|
||||
isExpression,
|
||||
isINodePropertyCollection,
|
||||
jsonParse,
|
||||
jsonStringify,
|
||||
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';
|
||||
@@ -64,6 +56,19 @@ import { OwnershipService } from '@/services/ownership.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
||||
import {
|
||||
CredentialDependencyService,
|
||||
type CredentialDependencyFilter,
|
||||
} from './credential-dependency.service';
|
||||
import { CredentialsFinderService } from './credentials-finder.service';
|
||||
import {
|
||||
validateAccessToReferencedSecretProviders,
|
||||
validateExternalSecretsPermissions,
|
||||
} from './validation';
|
||||
|
||||
/** Sentinel placed at every leaf of a redacted httpCustomAuth JSON shape */
|
||||
const CUSTOM_AUTH_JSON_REDACTED_VALUE = '***';
|
||||
|
||||
export type CredentialsGetSharedOptions =
|
||||
| { allowGlobalScope: true; globalScope: Scope }
|
||||
| { allowGlobalScope: false };
|
||||
@@ -609,7 +614,11 @@ export class CredentialsService {
|
||||
|
||||
const mergedData = deepCopy(data);
|
||||
if (mergedData.data) {
|
||||
mergedData.data = this.unredact(mergedData.data, decryptedData);
|
||||
mergedData.data = this.unredact(
|
||||
mergedData.data,
|
||||
decryptedData,
|
||||
this.getCredentialTypeProperties(existingCredential.type),
|
||||
);
|
||||
}
|
||||
|
||||
// This saves us a merge but requires some type casting. These
|
||||
@@ -826,15 +835,13 @@ export class CredentialsService {
|
||||
return props;
|
||||
};
|
||||
const properties = getExtendedProps(credType);
|
||||
const redacted = this.redactValues(copiedData, properties, credential.type);
|
||||
return redacted;
|
||||
return this.redactValues(copiedData, properties);
|
||||
}
|
||||
|
||||
private redactValues(
|
||||
data: ICredentialDataDecryptedObject,
|
||||
props: INodeProperties[],
|
||||
credentialType?: string,
|
||||
) {
|
||||
): ICredentialDataDecryptedObject {
|
||||
for (const dataKey of Object.keys(data)) {
|
||||
// The frontend only cares that this value isn't falsy.
|
||||
if (dataKey === 'oauthTokenData' || dataKey === 'csrfSecret') {
|
||||
@@ -869,15 +876,22 @@ export class CredentialsService {
|
||||
} else {
|
||||
data[dataKey] = CREDENTIAL_EMPTY_VALUE;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Auth: mask JSON after save (not marked as secret; redacted by type + field)
|
||||
if (credentialType === 'httpCustomAuth' && 'json' in data) {
|
||||
data.json =
|
||||
data.json && String(data.json).length > 0
|
||||
? CREDENTIAL_BLANKING_VALUE
|
||||
: CREDENTIAL_EMPTY_VALUE;
|
||||
if (prop.typeOptions?.redactJsonLeaves) {
|
||||
const jsonStr = String(data[dataKey] ?? '');
|
||||
if (!jsonStr) {
|
||||
data[dataKey] = CREDENTIAL_EMPTY_VALUE;
|
||||
} else {
|
||||
try {
|
||||
const parsed = jsonParse<unknown>(jsonStr);
|
||||
data[dataKey] = JSON.stringify(this.redactJsonLeaves(parsed), null, 2);
|
||||
} catch {
|
||||
// Not parseable (e.g. an expression) — leave as-is
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -888,21 +902,54 @@ export class CredentialsService {
|
||||
const values = data?.[collectionValuesKey];
|
||||
if (Array.isArray(values)) {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
values[i] = this.redactValues(
|
||||
values[i] as ICredentialDataDecryptedObject,
|
||||
option.values,
|
||||
undefined,
|
||||
);
|
||||
values[i] = this.redactValues(values[i] as ICredentialDataDecryptedObject, option.values);
|
||||
}
|
||||
} else if (typeof values === 'object' && values !== null) {
|
||||
data[collectionValuesKey] = this.redactValues(
|
||||
values as ICredentialDataDecryptedObject,
|
||||
option.values,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private redactJsonLeaves(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) return obj.map((item) => this.redactJsonLeaves(item));
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
|
||||
k,
|
||||
this.redactJsonLeaves(v),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return CUSTOM_AUTH_JSON_REDACTED_VALUE;
|
||||
}
|
||||
|
||||
private mergeRedactedJsonLeaves(newVal: unknown, savedVal: unknown): unknown {
|
||||
if (newVal === CUSTOM_AUTH_JSON_REDACTED_VALUE) return savedVal;
|
||||
if (Array.isArray(newVal) && Array.isArray(savedVal)) {
|
||||
return newVal.map((item, i) => this.mergeRedactedJsonLeaves(item, savedVal[i]));
|
||||
}
|
||||
// Type mismatch involving arrays: user made a structural change — preserve new value as-is
|
||||
if (Array.isArray(newVal) || Array.isArray(savedVal)) {
|
||||
return newVal;
|
||||
}
|
||||
if (
|
||||
typeof newVal === 'object' &&
|
||||
newVal !== null &&
|
||||
typeof savedVal === 'object' &&
|
||||
savedVal !== null
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(newVal as Record<string, unknown>).map(([k, v]) => [
|
||||
k,
|
||||
this.mergeRedactedJsonLeaves(v, (savedVal as Record<string, unknown>)[k]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return newVal;
|
||||
}
|
||||
|
||||
private unredactRestoreValues(unmerged: any, replacement: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
for (const [key, value] of Object.entries(unmerged)) {
|
||||
@@ -924,15 +971,38 @@ export class CredentialsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Take unredacted data (probably from the DB) and merge it with
|
||||
// redacted data to create an unredacted version.
|
||||
getCredentialTypeProperties(credentialType: string): INodeProperties[] {
|
||||
try {
|
||||
return this.credentialTypes.getByName(credentialType).properties;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Take redacted data (e.g. from the frontend) and merge it with saved data to produce a
|
||||
// fully unredacted version ready for saving or testing.
|
||||
unredact(
|
||||
redactedData: ICredentialDataDecryptedObject,
|
||||
savedData: ICredentialDataDecryptedObject,
|
||||
props: INodeProperties[] = [],
|
||||
) {
|
||||
// Replace any blank sentinel values with their saved version
|
||||
// Replace field-level blank sentinels (CREDENTIAL_BLANKING_VALUE / CREDENTIAL_EMPTY_VALUE)
|
||||
const mergedData = deepCopy(redactedData);
|
||||
this.unredactRestoreValues(mergedData, savedData);
|
||||
|
||||
// Merge leaf-level *** sentinels for fields marked with typeOptions.redactJsonLeaves
|
||||
for (const prop of props) {
|
||||
if (!prop.typeOptions?.redactJsonLeaves) continue;
|
||||
if (!(prop.name in mergedData) || !(prop.name in savedData)) continue;
|
||||
try {
|
||||
const newJson: unknown = jsonParse(String(mergedData[prop.name]));
|
||||
const savedJson: unknown = jsonParse(String(savedData[prop.name]));
|
||||
mergedData[prop.name] = jsonStringify(this.mergeRedactedJsonLeaves(newJson, savedJson));
|
||||
} catch {
|
||||
// Not parseable JSON — leave as-is
|
||||
}
|
||||
}
|
||||
|
||||
return mergedData;
|
||||
}
|
||||
|
||||
|
||||
+20
-1
@@ -13,7 +13,7 @@ import type {
|
||||
INodeProperties,
|
||||
ITelemetryTrackProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { deepCopy, NodeHelpers } from 'n8n-workflow';
|
||||
import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow';
|
||||
import CredentialIcon from '../CredentialIcon.vue';
|
||||
|
||||
import CredentialConfig from './CredentialConfig.vue';
|
||||
@@ -301,6 +301,19 @@ const requiredPropertiesFilled = computed(() => {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (property.type === 'json' && credentialProperty) {
|
||||
const jsonValue = String(credentialProperty);
|
||||
const containsExpression = isExpression(jsonValue);
|
||||
// Sentinels represent a previously-saved value, and expressions are always valid
|
||||
if (!containsExpression && jsonValue !== CREDENTIAL_EMPTY_VALUE) {
|
||||
try {
|
||||
JSON.parse(jsonValue);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -856,6 +869,12 @@ async function saveCredential(): Promise<ICredentialsResponse | null> {
|
||||
credentialId.value = credential.id;
|
||||
currentCredential.value = credential;
|
||||
|
||||
// Re-fetch to display server-redacted JSON shape for credentials with leaf-redacted fields
|
||||
if (credentialProperties.value.some((p) => p.typeOptions?.redactJsonLeaves)) {
|
||||
await loadCurrentCredential(credential.id);
|
||||
setCredentialPropertyDefaults();
|
||||
}
|
||||
|
||||
if (isCredentialTestable.value) {
|
||||
isTesting.value = true;
|
||||
// Add the full data including defaults for testing
|
||||
|
||||
+31
-70
@@ -17,7 +17,6 @@ import type {
|
||||
NodeParameterValueType,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
CREDENTIAL_BLANKING_VALUE,
|
||||
CREDENTIAL_EMPTY_VALUE,
|
||||
IconOrEmojiSchema,
|
||||
isResourceLocatorValue,
|
||||
@@ -103,7 +102,6 @@ import { useBuilderStore } from '@/features/ai/assistant/builder.store';
|
||||
|
||||
import { ElColorPicker, ElDatePicker, ElDialog, ElSwitch } from 'element-plus';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nIcon,
|
||||
N8nIconPicker,
|
||||
N8nInput,
|
||||
@@ -271,23 +269,41 @@ const isJsonPasswordField = computed<boolean>(() => {
|
||||
return props.parameter.type === 'json' && isSecretParameter.value;
|
||||
});
|
||||
|
||||
function hasRedactedLeaf(obj: unknown): boolean {
|
||||
if (obj === '***') return true;
|
||||
if (Array.isArray(obj)) return obj.some(hasRedactedLeaf);
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
return Object.values(obj as Record<string, unknown>).some(hasRedactedLeaf);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Custom Auth: only credential with top-level "json" field; mask after save (backend redacts by type, not secret flag)
|
||||
const isCustomAuthJsonField = computed<boolean>(() => {
|
||||
return props.eventSource === 'credentials' && props.parameter.name === 'json';
|
||||
});
|
||||
const isCredentialJsonValueRedacted = computed<boolean>(() => {
|
||||
return (
|
||||
props.modelValue === CREDENTIAL_BLANKING_VALUE || props.modelValue === CREDENTIAL_EMPTY_VALUE
|
||||
);
|
||||
});
|
||||
const showCredentialJsonOverlay = computed<boolean>(() => {
|
||||
return isCustomAuthJsonField.value && isCredentialJsonValueRedacted.value;
|
||||
});
|
||||
// In dialog, when Custom Auth json is redacted show blank (never show placeholder)
|
||||
const credentialJsonEditorValue = computed<string>(() => {
|
||||
if (isCustomAuthJsonField.value && isCredentialJsonValueRedacted.value) {
|
||||
return '';
|
||||
const val = props.modelValue;
|
||||
if (val === CREDENTIAL_EMPTY_VALUE) return true;
|
||||
// New: detect shaped-redacted JSON (backend replaces leaf values with ***)
|
||||
if (isCustomAuthJsonField.value) {
|
||||
try {
|
||||
return hasRedactedLeaf(JSON.parse(String(val)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const isRedactedCustomAuthJson = computed<boolean>(
|
||||
() => isCustomAuthJsonField.value && isCredentialJsonValueRedacted.value,
|
||||
);
|
||||
|
||||
const credentialJsonEditorValue = computed<string>(() => {
|
||||
if (!isRedactedCustomAuthJson.value) return modelValueString.value;
|
||||
// Empty json field — show empty editor so the user knows to fill it in
|
||||
if (props.modelValue === CREDENTIAL_EMPTY_VALUE) return '';
|
||||
// Shaped-redacted JSON: backend already formatted with *** leaves — show as-is
|
||||
return modelValueString.value;
|
||||
});
|
||||
|
||||
@@ -1693,38 +1709,9 @@ onUpdated(async () => {
|
||||
</template>
|
||||
</JsEditor>
|
||||
|
||||
<div
|
||||
v-else-if="showCredentialJsonOverlay"
|
||||
:class="$style.credentialJsonOverlay"
|
||||
data-test-id="credential-json-overlay"
|
||||
>
|
||||
<JsonEditor
|
||||
:model-value="'***\n***\n***'"
|
||||
:is-read-only="true"
|
||||
:rows="editorRows"
|
||||
:class="$style.credentialJsonEditor"
|
||||
/>
|
||||
<div :class="$style.credentialJsonOverlayButton">
|
||||
<N8nButton
|
||||
size="small"
|
||||
secondary
|
||||
icon="pencil"
|
||||
data-test-id="credential-json-edit-button"
|
||||
@click="valueChanged('')"
|
||||
>
|
||||
{{ i18n.baseText('parameterInput.edit') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JsonEditor
|
||||
v-else-if="
|
||||
parameter.type === 'json' &&
|
||||
!codeEditDialogVisible &&
|
||||
!showCredentialJsonOverlay &&
|
||||
!isSecretParameter
|
||||
"
|
||||
:model-value="modelValueString"
|
||||
v-else-if="parameter.type === 'json' && !codeEditDialogVisible && !isSecretParameter"
|
||||
:model-value="credentialJsonEditorValue"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
@update:model-value="valueChangedDebounced"
|
||||
@@ -2288,30 +2275,4 @@ onUpdated(async () => {
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.credentialJsonOverlay {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.credentialJsonTextarea {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.credentialJsonTextarea :global(.input) {
|
||||
color: var(--color--text--tint-2);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.credentialJsonOverlayButton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.credentialJsonOverlayButton > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,6 +23,9 @@ export class HttpCustomAuth implements ICredentialType {
|
||||
placeholder:
|
||||
'{ "headers": { "key" : "value" }, "body": { "key": "value" }, "qs": { "key": "value" } }',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
redactJsonLeaves: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1623,6 +1623,7 @@ export interface INodePropertyTypeOptions {
|
||||
layout?: 'inline'; // Render sub-parameters side-by-side in a row
|
||||
};
|
||||
password?: boolean; // Supported by: string
|
||||
redactJsonLeaves?: boolean; // Supported by: json (credential fields only) — redacts leaf values instead of the whole field
|
||||
rows?: number; // Supported by: string
|
||||
showAlpha?: boolean; // Supported by: color
|
||||
sortable?: boolean; // Supported when "multipleValues" set to true
|
||||
|
||||
Reference in New Issue
Block a user