Skip to content

Commit

Permalink
fix(core): Redact credentials (#13263)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomi committed Feb 15, 2025
1 parent c68e23f commit e4af4b4
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 63 deletions.
18 changes: 12 additions & 6 deletions packages/cli/src/credentials/credentials.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export class CredentialsController {
}

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
// credential data are missing so the payload will be empty
Expand All @@ -143,14 +143,14 @@ export class CredentialsController {
mergedCredentials,
);

if (mergedCredentials.data && storedCredential) {
if (mergedCredentials.data) {
mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
);
}

return await this.credentialsService.test(req.user, mergedCredentials);
return await this.credentialsService.test(req.user.id, mergedCredentials);
}

@Post('/')
Expand All @@ -176,18 +176,22 @@ export class CredentialsController {
@Patch('/:credentialId')
@ProjectScope('credential:update')
async updateCredentials(req: CredentialRequest.Update) {
const { credentialId } = req.params;
const {
body,
user,
params: { credentialId },
} = req;

const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
req.user,
user,
['credential:update'],
);

if (!credential) {
this.logger.info('Attempt to update credential blocked due to lack of permissions', {
credentialId,
userId: req.user.id,
userId: user.id,
});
throw new NotFoundError(
'Credential to be updated not found. You can only update credentials owned by you',
Expand All @@ -199,6 +203,8 @@ export class CredentialsController {
}

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(
req.body,
decryptedData,
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/credentials/credentials.service.ee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class EnterpriseCredentialsService {
if (credential) {
// Decrypt the data if we found the credential with the `credential:update`
// scope.
decryptedData = this.credentialsService.decrypt(credential, true);
decryptedData = this.credentialsService.decrypt(credential);
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.
Expand All @@ -109,6 +109,11 @@ export class EnterpriseCredentialsService {
const { data: _, ...rest } = credential;

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 };
}

Expand Down
20 changes: 16 additions & 4 deletions packages/cli/src/credentials/credentials.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,16 @@ export class CredentialsService {

if (includeData) {
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 {
...c,
data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined,
data,
} as unknown as CredentialsEntity;
});
}
Expand Down Expand Up @@ -428,8 +435,8 @@ export class CredentialsService {
await this.credentialsRepository.remove(credential);
}

async test(user: User, credentials: ICredentialsDecrypted) {
return await this.credentialsTester.testCredentials(user, credentials.type, credentials);
async test(userId: User['id'], credentials: ICredentialsDecrypted) {
return await this.credentialsTester.testCredentials(userId, credentials.type, credentials);
}

// Take data and replace all sensitive values with a sentinel value.
Expand Down Expand Up @@ -540,7 +547,7 @@ export class CredentialsService {
if (sharing) {
// Decrypt the data if we found the credential with the `credential:update`
// scope.
decryptedData = this.decrypt(sharing.credentials, true);
decryptedData = this.decrypt(sharing.credentials);
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.
Expand All @@ -556,6 +563,11 @@ export class CredentialsService {
const { data: _, ...rest } = credential;

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 { ...rest };
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/services/credentials-tester.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export class CredentialsTester {

// eslint-disable-next-line complexity
async testCredentials(
user: User,
userId: User['id'],
credentialType: string,
credentialsDecrypted: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
Expand All @@ -184,7 +184,7 @@ export class CredentialsTester {

if (credentialsDecrypted.data) {
try {
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id);
const additionalData = await WorkflowExecuteAdditionalData.getBase(userId);
credentialsDecrypted.data = this.credentialsHelper.applyDefaultsAndOverwrites(
additionalData,
credentialsDecrypted.data,
Expand Down Expand Up @@ -291,7 +291,7 @@ export class CredentialsTester {
},
};

const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id, node.parameters);
const additionalData = await WorkflowExecuteAdditionalData.getBase(userId, node.parameters);

const routingNode = new RoutingNode(
workflow,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
createUser,
createUserShell,
} from '../shared/db/users';
import { randomCredentialPayload } from '../shared/random';
import {
randomCredentialPayload,
randomCredentialPayloadWithOauthTokenData,
} from '../shared/random';
import * as testDb from '../shared/test-db';
import type { SaveCredentialFunction } from '../shared/types';
import type { SuperAgentTest } from '../shared/types';
Expand Down Expand Up @@ -556,10 +559,11 @@ describe('GET /credentials/:id', () => {
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 redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), {
const credential = randomCredentialPayloadWithOauthTokenData();
const savedCredential = await saveCredential(credential, {
user: owner,
});

Expand All @@ -569,7 +573,8 @@ describe('GET /credentials/:id', () => {

validateMainCredentialData(response.body.data);
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 () => {
Expand Down
Loading

0 comments on commit e4af4b4

Please sign in to comment.