fix(core): Redact credentials (#13263)

This commit is contained in:
Tomi Turtiainen 2025-02-14 16:46:21 +02:00 committed by GitHub
parent d116f121e3
commit 052f17744d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 157 additions and 63 deletions

View file

@ -130,7 +130,7 @@ export class CredentialsController {
} }
const mergedCredentials = deepCopy(credentials); const mergedCredentials = deepCopy(credentials);
const decryptedData = this.credentialsService.decrypt(storedCredential); const decryptedData = this.credentialsService.decrypt(storedCredential, true);
// When a sharee (or project viewer) opens a credential, the fields and the // When a sharee (or project viewer) opens a credential, the fields and the
// credential data are missing so the payload will be empty // credential data are missing so the payload will be empty
@ -143,14 +143,14 @@ export class CredentialsController {
mergedCredentials, mergedCredentials,
); );
if (mergedCredentials.data && storedCredential) { if (mergedCredentials.data) {
mergedCredentials.data = this.credentialsService.unredact( mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data, mergedCredentials.data,
decryptedData, decryptedData,
); );
} }
return await this.credentialsService.test(req.user, mergedCredentials); return await this.credentialsService.test(req.user.id, mergedCredentials);
} }
@Post('/') @Post('/')
@ -176,18 +176,22 @@ export class CredentialsController {
@Patch('/:credentialId') @Patch('/:credentialId')
@ProjectScope('credential:update') @ProjectScope('credential:update')
async updateCredentials(req: CredentialRequest.Update) { async updateCredentials(req: CredentialRequest.Update) {
const { credentialId } = req.params; const {
body,
user,
params: { credentialId },
} = req;
const credential = await this.sharedCredentialsRepository.findCredentialForUser( const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId, credentialId,
req.user, user,
['credential:update'], ['credential:update'],
); );
if (!credential) { if (!credential) {
this.logger.info('Attempt to update credential blocked due to lack of permissions', { this.logger.info('Attempt to update credential blocked due to lack of permissions', {
credentialId, credentialId,
userId: req.user.id, userId: user.id,
}); });
throw new NotFoundError( throw new NotFoundError(
'Credential to be updated not found. You can only update credentials owned by you', 'Credential to be updated not found. You can only update credentials owned by you',
@ -199,6 +203,8 @@ export class CredentialsController {
} }
const decryptedData = this.credentialsService.decrypt(credential, true); const decryptedData = this.credentialsService.decrypt(credential, true);
// We never want to allow users to change the oauthTokenData
delete body.data?.oauthTokenData;
const preparedCredentialData = await this.credentialsService.prepareUpdateData( const preparedCredentialData = await this.credentialsService.prepareUpdateData(
req.body, req.body,
decryptedData, decryptedData,

View file

@ -87,7 +87,7 @@ export class EnterpriseCredentialsService {
if (credential) { if (credential) {
// Decrypt the data if we found the credential with the `credential:update` // Decrypt the data if we found the credential with the `credential:update`
// scope. // scope.
decryptedData = this.credentialsService.decrypt(credential, true); decryptedData = this.credentialsService.decrypt(credential);
} else { } else {
// Otherwise try to find them with only the `credential:read` scope. In // Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data. // that case we return them without the decrypted data.
@ -109,6 +109,11 @@ export class EnterpriseCredentialsService {
const { data: _, ...rest } = credential; const { data: _, ...rest } = credential;
if (decryptedData) { if (decryptedData) {
// We never want to expose the oauthTokenData to the frontend, but it
// expects it to check if the credential is already connected.
if (decryptedData?.oauthTokenData) {
decryptedData.oauthTokenData = true;
}
return { data: decryptedData, ...rest }; return { data: decryptedData, ...rest };
} }

View file

@ -112,9 +112,16 @@ export class CredentialsService {
if (includeData) { if (includeData) {
credentials = credentials.map((c: CredentialsEntity & ScopesField) => { credentials = credentials.map((c: CredentialsEntity & ScopesField) => {
const data = c.scopes.includes('credential:update') ? this.decrypt(c) : undefined;
// We never want to expose the oauthTokenData to the frontend, but it
// expects it to check if the credential is already connected.
if (data?.oauthTokenData) {
data.oauthTokenData = true;
}
return { return {
...c, ...c,
data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined, data,
} as unknown as CredentialsEntity; } as unknown as CredentialsEntity;
}); });
} }
@ -441,8 +448,8 @@ export class CredentialsService {
await this.credentialsRepository.remove(credential); await this.credentialsRepository.remove(credential);
} }
async test(user: User, credentials: ICredentialsDecrypted) { async test(userId: User['id'], credentials: ICredentialsDecrypted) {
return await this.credentialsTester.testCredentials(user, credentials.type, credentials); return await this.credentialsTester.testCredentials(userId, credentials.type, credentials);
} }
// Take data and replace all sensitive values with a sentinel value. // Take data and replace all sensitive values with a sentinel value.
@ -553,7 +560,7 @@ export class CredentialsService {
if (sharing) { if (sharing) {
// Decrypt the data if we found the credential with the `credential:update` // Decrypt the data if we found the credential with the `credential:update`
// scope. // scope.
decryptedData = this.decrypt(sharing.credentials, true); decryptedData = this.decrypt(sharing.credentials);
} else { } else {
// Otherwise try to find them with only the `credential:read` scope. In // Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data. // that case we return them without the decrypted data.
@ -569,6 +576,11 @@ export class CredentialsService {
const { data: _, ...rest } = credential; const { data: _, ...rest } = credential;
if (decryptedData) { if (decryptedData) {
// We never want to expose the oauthTokenData to the frontend, but it
// expects it to check if the credential is already connected.
if (decryptedData?.oauthTokenData) {
decryptedData.oauthTokenData = true;
}
return { data: decryptedData, ...rest }; return { data: decryptedData, ...rest };
} }
return { ...rest }; return { ...rest };

View file

@ -172,7 +172,7 @@ export class CredentialsTester {
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
async testCredentials( async testCredentials(
user: User, userId: User['id'],
credentialType: string, credentialType: string,
credentialsDecrypted: ICredentialsDecrypted, credentialsDecrypted: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> { ): Promise<INodeCredentialTestResult> {
@ -186,7 +186,7 @@ export class CredentialsTester {
if (credentialsDecrypted.data) { if (credentialsDecrypted.data) {
try { try {
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); const additionalData = await WorkflowExecuteAdditionalData.getBase(userId);
credentialsDecrypted.data = this.credentialsHelper.applyDefaultsAndOverwrites( credentialsDecrypted.data = this.credentialsHelper.applyDefaultsAndOverwrites(
additionalData, additionalData,
credentialsDecrypted.data, credentialsDecrypted.data,
@ -292,7 +292,7 @@ export class CredentialsTester {
}, },
}; };
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id, node.parameters); const additionalData = await WorkflowExecuteAdditionalData.getBase(userId, node.parameters);
const executeData: IExecuteData = { node, data: {}, source: null }; const executeData: IExecuteData = { node, data: {}, source: null };
const executeFunctions = new ExecuteContext( const executeFunctions = new ExecuteContext(

View file

@ -28,7 +28,10 @@ import {
createUser, createUser,
createUserShell, createUserShell,
} from '../shared/db/users'; } from '../shared/db/users';
import { randomCredentialPayload } from '../shared/random'; import {
randomCredentialPayload,
randomCredentialPayloadWithOauthTokenData,
} from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SaveCredentialFunction } from '../shared/types'; import type { SaveCredentialFunction } from '../shared/types';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
@ -556,10 +559,11 @@ describe('GET /credentials/:id', () => {
expect(secondCredential.data).toBeDefined(); expect(secondCredential.data).toBeDefined();
}); });
test('should not redact the data when `includeData:true` is passed', async () => { test('should redact the data when `includeData:true` is passed', async () => {
const credentialService = Container.get(CredentialsService); const credentialService = Container.get(CredentialsService);
const redactSpy = jest.spyOn(credentialService, 'redact'); const redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), { const credential = randomCredentialPayloadWithOauthTokenData();
const savedCredential = await saveCredential(credential, {
user: owner, user: owner,
}); });
@ -569,7 +573,8 @@ describe('GET /credentials/:id', () => {
validateMainCredentialData(response.body.data); validateMainCredentialData(response.body.data);
expect(response.body.data.data).toBeDefined(); expect(response.body.data.data).toBeDefined();
expect(redactSpy).not.toHaveBeenCalled(); expect(response.body.data.data.oauthTokenData).toBe(true);
expect(redactSpy).toHaveBeenCalled();
}); });
test('should retrieve non-owned cred for owner', async () => { test('should retrieve non-owned cred for owner', async () => {

View file

@ -1,7 +1,9 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { Scope } from '@sentry/node'; import type { Scope } from '@sentry/node';
import { mock } from 'jest-mock-extended';
import { Credentials } from 'n8n-core'; import { Credentials } from 'n8n-core';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { randomString } from 'n8n-workflow'; import { randomString } from 'n8n-workflow';
import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
@ -12,8 +14,10 @@ import { CredentialsRepository } from '@/databases/repositories/credentials.repo
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import type { ListQuery } from '@/requests'; import type { ListQuery } from '@/requests';
import { CredentialsTester } from '@/services/credentials-tester.service';
import { import {
getCredentialById,
saveCredential, saveCredential,
shareCredentialWithProjects, shareCredentialWithProjects,
shareCredentialWithUsers, shareCredentialWithUsers,
@ -23,6 +27,7 @@ import { createManyUsers, createMember, createOwner } from '../shared/db/users';
import { import {
randomCredentialPayload as payload, randomCredentialPayload as payload,
randomCredentialPayload, randomCredentialPayload,
randomCredentialPayloadWithOauthTokenData,
randomName, randomName,
} from '../shared/random'; } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
@ -319,7 +324,7 @@ describe('GET /credentials', () => {
] = await Promise.all([ ] = await Promise.all([
saveCredential(randomCredentialPayload(), { user: owner, role: 'credential:owner' }), saveCredential(randomCredentialPayload(), { user: owner, role: 'credential:owner' }),
saveCredential(randomCredentialPayload(), { user: member, role: 'credential:owner' }), saveCredential(randomCredentialPayload(), { user: member, role: 'credential:owner' }),
saveCredential(randomCredentialPayload(), { saveCredential(randomCredentialPayloadWithOauthTokenData(), {
project: teamProjectViewer, project: teamProjectViewer,
role: 'credential:owner', role: 'credential:owner',
}), }),
@ -370,6 +375,9 @@ describe('GET /credentials', () => {
expect(teamCredAsViewer.id).toBe(teamCredentialAsViewer.id); expect(teamCredAsViewer.id).toBe(teamCredentialAsViewer.id);
expect(teamCredAsViewer.data).toBeDefined(); expect(teamCredAsViewer.data).toBeDefined();
expect(
(teamCredAsViewer.data as unknown as ICredentialDataDecryptedObject).oauthTokenData,
).toBe(true);
expect(teamCredAsViewer.scopes).toEqual( expect(teamCredAsViewer.scopes).toEqual(
[ [
'credential:move', 'credential:move',
@ -1165,55 +1173,19 @@ describe('PATCH /credentials/:id', () => {
expect(shellCredential.name).toBe(patchPayload.name); // updated expect(shellCredential.name).toBe(patchPayload.name); // updated
}); });
test('should not store redacted value in the db for oauthTokenData', async () => {
// ARRANGE
const credentialService = Container.get(CredentialsService);
const redactSpy = jest.spyOn(credentialService, 'redact').mockReturnValueOnce({
accessToken: CREDENTIAL_BLANKING_VALUE,
oauthTokenData: CREDENTIAL_BLANKING_VALUE,
});
const payload = randomCredentialPayload();
payload.data.oauthTokenData = { tokenData: true };
const savedCredential = await saveCredential(payload, {
user: owner,
role: 'credential:owner',
});
// ACT
const patchPayload = { ...payload, data: { foo: 'bar' } };
await authOwnerAgent.patch(`/credentials/${savedCredential.id}`).send(patchPayload).expect(200);
// ASSERT
const response = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`)
.query({ includeData: true })
.expect(200);
const { id, data } = response.body.data;
expect(id).toBe(savedCredential.id);
expect(data).toEqual({
...patchPayload.data,
// should be the original
oauthTokenData: payload.data.oauthTokenData,
});
expect(redactSpy).not.toHaveBeenCalled();
});
test('should not allow to overwrite oauthTokenData', async () => { test('should not allow to overwrite oauthTokenData', async () => {
// ARRANGE // ARRANGE
const payload = randomCredentialPayload(); const credential = randomCredentialPayload();
payload.data.oauthTokenData = { tokenData: true }; credential.data.oauthTokenData = { access_token: 'foo' };
const savedCredential = await saveCredential(payload, { const savedCredential = await saveCredential(credential, {
user: owner, user: owner,
role: 'credential:owner', role: 'credential:owner',
}); });
// ACT // ACT
const patchPayload = { const patchPayload = {
...payload, ...credential,
data: { accessToken: 'new', oauthTokenData: { tokenData: false } }, data: { accessToken: 'new', oauthTokenData: { access_token: 'bar' } },
}; };
await authOwnerAgent.patch(`/credentials/${savedCredential.id}`).send(patchPayload).expect(200); await authOwnerAgent.patch(`/credentials/${savedCredential.id}`).send(patchPayload).expect(200);
@ -1229,7 +1201,9 @@ describe('PATCH /credentials/:id', () => {
// was overwritten // was overwritten
expect(data.accessToken).toBe(patchPayload.data.accessToken); expect(data.accessToken).toBe(patchPayload.data.accessToken);
// was not overwritten // was not overwritten
expect(data.oauthTokenData).toEqual(payload.data.oauthTokenData); const dbCredential = await getCredentialById(savedCredential.id);
const unencryptedData = Container.get(CredentialsService).decrypt(dbCredential!);
expect(unencryptedData.oauthTokenData).toEqual(credential.data.oauthTokenData);
}); });
test('should fail with invalid inputs', async () => { test('should fail with invalid inputs', async () => {
@ -1341,7 +1315,7 @@ describe('GET /credentials/:id', () => {
expect(secondResponse.body.data.data).toBeDefined(); expect(secondResponse.body.data.data).toBeDefined();
}); });
test('should not redact the data when `includeData:true` is passed', async () => { test('should redact the data when `includeData:true` is passed', async () => {
const credentialService = Container.get(CredentialsService); const credentialService = Container.get(CredentialsService);
const redactSpy = jest.spyOn(credentialService, 'redact'); const redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), { const savedCredential = await saveCredential(randomCredentialPayload(), {
@ -1355,7 +1329,23 @@ describe('GET /credentials/:id', () => {
validateMainCredentialData(response.body.data); validateMainCredentialData(response.body.data);
expect(response.body.data.data).toBeDefined(); expect(response.body.data.data).toBeDefined();
expect(redactSpy).not.toHaveBeenCalled(); expect(redactSpy).toHaveBeenCalled();
});
test('should omit oauth data when `includeData:true` is passed', async () => {
const credential = randomCredentialPayloadWithOauthTokenData();
const savedCredential = await saveCredential(credential, {
user: owner,
role: 'credential:owner',
});
const response = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`)
.query({ includeData: true });
validateMainCredentialData(response.body.data);
expect(response.body.data.data).toBeDefined();
expect(response.body.data.data.oauthTokenData).toBe(true);
}); });
test('should retrieve owned cred for member', async () => { test('should retrieve owned cred for member', async () => {
@ -1425,6 +1415,73 @@ describe('GET /credentials/:id', () => {
}); });
}); });
describe('POST /credentials/test', () => {
const mockCredentialsTester = mock<CredentialsTester>();
Container.set(CredentialsTester, mockCredentialsTester);
afterEach(() => {
mockCredentialsTester.testCredentials.mockClear();
});
test('should test a credential with unredacted data', async () => {
mockCredentialsTester.testCredentials.mockResolvedValue({
status: 'OK',
message: 'Credential tested successfully',
});
const credential = randomCredentialPayload();
const savedCredential = await saveCredential(credential, {
user: owner,
role: 'credential:owner',
});
const response = await authOwnerAgent.post('/credentials/test').send({
credentials: {
id: savedCredential.id,
type: savedCredential.type,
data: credential.data,
},
});
expect(response.statusCode).toBe(200);
expect(mockCredentialsTester.testCredentials.mock.calls[0][0]).toEqual(owner.id);
expect(mockCredentialsTester.testCredentials.mock.calls[0][1]).toBe(savedCredential.type);
expect(mockCredentialsTester.testCredentials.mock.calls[0][2]).toEqual({
id: savedCredential.id,
type: savedCredential.type,
data: credential.data,
});
});
test('should test a credential with redacted data', async () => {
mockCredentialsTester.testCredentials.mockResolvedValue({
status: 'OK',
message: 'Credential tested successfully',
});
const credential = randomCredentialPayload();
const savedCredential = await saveCredential(credential, {
user: owner,
role: 'credential:owner',
});
const response = await authOwnerAgent.post('/credentials/test').send({
credentials: {
id: savedCredential.id,
type: savedCredential.type,
data: {
accessToken: CREDENTIAL_BLANKING_VALUE,
},
},
});
expect(response.statusCode).toBe(200);
expect(mockCredentialsTester.testCredentials.mock.calls[0][0]).toEqual(owner.id);
expect(mockCredentialsTester.testCredentials.mock.calls[0][1]).toBe(savedCredential.type);
expect(mockCredentialsTester.testCredentials.mock.calls[0][2]).toEqual({
id: savedCredential.id,
type: savedCredential.type,
data: credential.data,
});
});
});
const INVALID_PAYLOADS = [ const INVALID_PAYLOADS = [
{ {
type: randomName(), type: randomName(),

View file

@ -46,4 +46,13 @@ export const randomCredentialPayload = ({
isManaged, isManaged,
}); });
export const randomCredentialPayloadWithOauthTokenData = ({
isManaged = false,
}: { isManaged?: boolean } = {}): CredentialPayload => ({
name: randomName(),
type: randomName(),
data: { accessToken: randomString(6, 16), oauthTokenData: { access_token: randomString(6, 16) } },
isManaged,
});
export const uniqueId = () => uuid(); export const uniqueId = () => uuid();