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
7 changed files with 157 additions and 63 deletions

View File

@@ -1,7 +1,9 @@
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { Scope } from '@sentry/node';
import { mock } from 'jest-mock-extended';
import { Credentials } from 'n8n-core';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { randomString } from 'n8n-workflow';
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 { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import type { ListQuery } from '@/requests';
import { CredentialsTester } from '@/services/credentials-tester.service';
import {
getCredentialById,
saveCredential,
shareCredentialWithProjects,
shareCredentialWithUsers,
@@ -23,6 +27,7 @@ import { createManyUsers, createMember, createOwner } from '../shared/db/users';
import {
randomCredentialPayload as payload,
randomCredentialPayload,
randomCredentialPayloadWithOauthTokenData,
randomName,
} from '../shared/random';
import * as testDb from '../shared/test-db';
@@ -319,7 +324,7 @@ describe('GET /credentials', () => {
] = await Promise.all([
saveCredential(randomCredentialPayload(), { user: owner, role: 'credential:owner' }),
saveCredential(randomCredentialPayload(), { user: member, role: 'credential:owner' }),
saveCredential(randomCredentialPayload(), {
saveCredential(randomCredentialPayloadWithOauthTokenData(), {
project: teamProjectViewer,
role: 'credential:owner',
}),
@@ -370,6 +375,9 @@ describe('GET /credentials', () => {
expect(teamCredAsViewer.id).toBe(teamCredentialAsViewer.id);
expect(teamCredAsViewer.data).toBeDefined();
expect(
(teamCredAsViewer.data as unknown as ICredentialDataDecryptedObject).oauthTokenData,
).toBe(true);
expect(teamCredAsViewer.scopes).toEqual(
[
'credential:move',
@@ -1165,55 +1173,19 @@ describe('PATCH /credentials/:id', () => {
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 () => {
// ARRANGE
const payload = randomCredentialPayload();
payload.data.oauthTokenData = { tokenData: true };
const savedCredential = await saveCredential(payload, {
const credential = randomCredentialPayload();
credential.data.oauthTokenData = { access_token: 'foo' };
const savedCredential = await saveCredential(credential, {
user: owner,
role: 'credential:owner',
});
// ACT
const patchPayload = {
...payload,
data: { accessToken: 'new', oauthTokenData: { tokenData: false } },
...credential,
data: { accessToken: 'new', oauthTokenData: { access_token: 'bar' } },
};
await authOwnerAgent.patch(`/credentials/${savedCredential.id}`).send(patchPayload).expect(200);
@@ -1229,7 +1201,9 @@ describe('PATCH /credentials/:id', () => {
// was overwritten
expect(data.accessToken).toBe(patchPayload.data.accessToken);
// 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 () => {
@@ -1341,7 +1315,7 @@ describe('GET /credentials/:id', () => {
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 redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), {
@@ -1355,7 +1329,23 @@ describe('GET /credentials/:id', () => {
validateMainCredentialData(response.body.data);
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 () => {
@@ -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 = [
{
type: randomName(),