mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
fix(core): Improve the security on OAuth callback endpoints (#11593)
This commit is contained in:
committed by
GitHub
parent
c265d44841
commit
274fcf45d3
@@ -6,24 +6,37 @@ import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } f
|
||||
import { jsonParse, ApplicationError } from 'n8n-workflow';
|
||||
import { Service } from 'typedi';
|
||||
|
||||
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||
import { RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
|
||||
import { CredentialsHelper } from '@/credentials-helper';
|
||||
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
||||
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
|
||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import type { ICredentialsDb } from '@/interfaces';
|
||||
import { Logger } from '@/logging/logger.service';
|
||||
import type { OAuthRequest } from '@/requests';
|
||||
import type { AuthenticatedRequest, OAuthRequest } from '@/requests';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
|
||||
export interface CsrfStateParam {
|
||||
type CsrfStateParam = {
|
||||
/** Id of the oAuth credential in the DB */
|
||||
cid: string;
|
||||
/** Random CSRF token, used to verify the signature of the CSRF state */
|
||||
token: string;
|
||||
}
|
||||
/** Creation timestamp of the CSRF state. Used for expiration. */
|
||||
createdAt: number;
|
||||
/** User who initiated OAuth flow, included to prevent cross-user credential hijacking. Optional only if `skipAuthOnOAuthCallback` is enabled. */
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
const MAX_CSRF_AGE = 5 * Time.minutes.toMilliseconds;
|
||||
|
||||
// TODO: Flip this flag in v2
|
||||
// https://linear.app/n8n/issue/CAT-329
|
||||
export const skipAuthOnOAuthCallback = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK !== 'true';
|
||||
|
||||
@Service()
|
||||
export abstract class AbstractOAuthController {
|
||||
@@ -118,35 +131,73 @@ export abstract class AbstractOAuthController {
|
||||
return await this.credentialsRepository.findOneBy({ id: credentialId });
|
||||
}
|
||||
|
||||
createCsrfState(credentialsId: string): [string, string] {
|
||||
createCsrfState(credentialsId: string, userId?: string): [string, string] {
|
||||
const token = new Csrf();
|
||||
const csrfSecret = token.secretSync();
|
||||
const state: CsrfStateParam = {
|
||||
token: token.create(csrfSecret),
|
||||
cid: credentialsId,
|
||||
createdAt: Date.now(),
|
||||
userId,
|
||||
};
|
||||
return [csrfSecret, Buffer.from(JSON.stringify(state)).toString('base64')];
|
||||
}
|
||||
|
||||
protected decodeCsrfState(encodedState: string): CsrfStateParam {
|
||||
protected decodeCsrfState(encodedState: string, req: AuthenticatedRequest): CsrfStateParam {
|
||||
const errorMessage = 'Invalid state format';
|
||||
const decoded = jsonParse<CsrfStateParam>(Buffer.from(encodedState, 'base64').toString(), {
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
if (typeof decoded.cid !== 'string' || typeof decoded.token !== 'string') {
|
||||
throw new ApplicationError(errorMessage);
|
||||
}
|
||||
|
||||
if (decoded.userId !== req.user?.id) {
|
||||
throw new AuthError('Unauthorized');
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
protected verifyCsrfState(decrypted: ICredentialDataDecryptedObject, state: CsrfStateParam) {
|
||||
protected verifyCsrfState(
|
||||
decrypted: ICredentialDataDecryptedObject & { csrfSecret?: string },
|
||||
state: CsrfStateParam,
|
||||
) {
|
||||
const token = new Csrf();
|
||||
|
||||
return (
|
||||
decrypted.csrfSecret === undefined ||
|
||||
!token.verify(decrypted.csrfSecret as string, state.token)
|
||||
Date.now() - state.createdAt <= MAX_CSRF_AGE &&
|
||||
decrypted.csrfSecret !== undefined &&
|
||||
token.verify(decrypted.csrfSecret, state.token)
|
||||
);
|
||||
}
|
||||
|
||||
protected async resolveCredential<T>(
|
||||
req: OAuthRequest.OAuth1Credential.Callback | OAuthRequest.OAuth2Credential.Callback,
|
||||
): Promise<[ICredentialsDb, ICredentialDataDecryptedObject, T]> {
|
||||
const { state: encodedState } = req.query;
|
||||
const state = this.decodeCsrfState(encodedState, req);
|
||||
const credential = await this.getCredentialWithoutUser(state.cid);
|
||||
if (!credential) {
|
||||
throw new ApplicationError('OAuth callback failed because of insufficient permissions');
|
||||
}
|
||||
|
||||
const additionalData = await this.getAdditionalData();
|
||||
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
|
||||
const oauthCredentials = this.applyDefaultsAndOverwrites<T>(
|
||||
credential,
|
||||
decryptedDataOriginal,
|
||||
additionalData,
|
||||
);
|
||||
|
||||
if (!this.verifyCsrfState(decryptedDataOriginal, state)) {
|
||||
throw new ApplicationError('The OAuth callback state is invalid!');
|
||||
}
|
||||
|
||||
return [credential, decryptedDataOriginal, oauthCredentials];
|
||||
}
|
||||
|
||||
protected renderCallbackError(res: Response, message: string, reason?: string) {
|
||||
res.render('oauth-error-callback', { error: { message, reason } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user