Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 212 additions & 3 deletions src/common/guards/webhook-signature.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import { WebhookSignatureGuard } from './webhook-signature.guard';
import type { WebhookSecretResolver } from './webhook-signature.guard';
import {
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
Expand Down Expand Up @@ -91,8 +92,216 @@ describe('WebhookSignatureGuard', () => {
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
});

it('should reject requests with missing headers', () => {
const context = createMockContext({}, { foo: 'bar' });
it('should reject requests with missing signature header', () => {
const context = createMockContext(
{ [WEBHOOK_TIMESTAMP_HEADER]: Math.floor(Date.now() / 1000).toString() },
{ foo: 'bar' },
);
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
});

it('should reject requests with missing timestamp header', () => {
const context = createMockContext(
{ [WEBHOOK_SIGNATURE_HEADER]: 'abc123' },
{ foo: 'bar' },
);
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
});

it('should reject requests with non-numeric timestamp', () => {
const context = createMockContext(
{
[WEBHOOK_SIGNATURE_HEADER]: 'abc123',
[WEBHOOK_TIMESTAMP_HEADER]: 'not-a-number',
},
{ foo: 'bar' },
);
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
});

it('should reject when no secret is configured', () => {
const noSecretConfig = {
get: () => null,
} as unknown as ConfigService;
const guardNoSecret = new WebhookSignatureGuard(noSecretConfig);

const timestamp = Math.floor(Date.now() / 1000);
const context = createMockContext(
{
[WEBHOOK_SIGNATURE_HEADER]: 'abc123',
[WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(),
},
{ foo: 'bar' },
);

expect(() => guardNoSecret.canActivate(context)).toThrow(UnauthorizedException);
});

describe('per-integration secret resolver', () => {
it('should use custom secret resolver when provided', () => {
const customSecret = 'integration-specific-secret';
const secretResolver: WebhookSecretResolver = vi.fn().mockReturnValue(customSecret);

const guardWithResolver = new WebhookSignatureGuard(mockConfigService, {
secretResolver,
});

const timestamp = Math.floor(Date.now() / 1000);
const body = { event: 'test' };
const rawBody = JSON.stringify(body);

const hmac = crypto.createHmac('sha256', customSecret);
hmac.update(`${timestamp}.${rawBody}`);
const signature = hmac.digest('hex');

const context = createMockContext(
{
[WEBHOOK_SIGNATURE_HEADER]: signature,
[WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(),
'x-integration-id': 'integration-123',
},
body,
);

expect(guardWithResolver.canActivate(context)).toBe(true);
expect(secretResolver).toHaveBeenCalledWith(
expect.objectContaining({ headers: expect.any(Object) }),
'integration-123',
);
});

it('should fall back to global secret when resolver returns undefined', () => {
const secretResolver: WebhookSecretResolver = vi.fn().mockReturnValue(undefined);

const guardWithResolver = new WebhookSignatureGuard(mockConfigService, {
secretResolver,
});

const timestamp = Math.floor(Date.now() / 1000);
const body = { event: 'test' };
const rawBody = JSON.stringify(body);

const hmac = crypto.createHmac('sha256', secret);
hmac.update(`${timestamp}.${rawBody}`);
const signature = hmac.digest('hex');

const context = createMockContext(
{
[WEBHOOK_SIGNATURE_HEADER]: signature,
[WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(),
},
body,
);

expect(guardWithResolver.canActivate(context)).toBe(true);
});

it('should reject with custom secret when signature does not match', () => {
const customSecret = 'integration-specific-secret';
const secretResolver: WebhookSecretResolver = vi.fn().mockReturnValue(customSecret);

const guardWithResolver = new WebhookSignatureGuard(mockConfigService, {
secretResolver,
});

const timestamp = Math.floor(Date.now() / 1000);
const body = { event: 'test' };
const rawBody = JSON.stringify(body);

// Sign with the WRONG secret
const hmac = crypto.createHmac('sha256', 'wrong-secret');
hmac.update(`${timestamp}.${rawBody}`);
const signature = hmac.digest('hex');

const context = createMockContext(
{
[WEBHOOK_SIGNATURE_HEADER]: signature,
[WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(),
},
body,
);

expect(() => guardWithResolver.canActivate(context)).toThrow(UnauthorizedException);
});
});

describe('custom tolerance', () => {
it('should respect custom tolerance window', () => {
const guardShortTolerance = new WebhookSignatureGuard(mockConfigService, {
toleranceSeconds: 60, // 1 minute
});

const timestamp = Math.floor(Date.now() / 1000) - 90; // 90 seconds ago
const body = { event: 'test' };
const rawBody = JSON.stringify(body);

const hmac = crypto.createHmac('sha256', secret);
hmac.update(`${timestamp}.${rawBody}`);
const signature = hmac.digest('hex');

const context = createMockContext(
{
[WEBHOOK_SIGNATURE_HEADER]: signature,
[WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(),
},
body,
);

expect(() => guardShortTolerance.canActivate(context)).toThrow(UnauthorizedException);
});
});

describe('raw body extraction', () => {
it('should prefer rawBody over parsed body for HMAC computation', () => {
const timestamp = Math.floor(Date.now() / 1000);
const rawBody = '{"event":"test","data":{"a":1}}';

const hmac = crypto.createHmac('sha256', secret);
hmac.update(`${timestamp}.${rawBody}`);
const signature = hmac.digest('hex');

const request = {
headers: {
[WEBHOOK_SIGNATURE_HEADER]: signature,
[WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(),
},
body: { event: 'test', data: { a: 1 } }, // parsed body (may have different key order)
rawBody: Buffer.from(rawBody),
};

const context = {
switchToHttp: () => ({
getRequest: () => request,
}),
} as unknown as ExecutionContext;

expect(guard.canActivate(context)).toBe(true);
});

it('should fall back to JSON.stringify when rawBody is not set', () => {
const timestamp = Math.floor(Date.now() / 1000);
const body = { event: 'test' };
const rawBody = JSON.stringify(body);

const hmac = crypto.createHmac('sha256', secret);
hmac.update(`${timestamp}.${rawBody}`);
const signature = hmac.digest('hex');

const request = {
headers: {
[WEBHOOK_SIGNATURE_HEADER]: signature,
[WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(),
},
body,
};

const context = {
switchToHttp: () => ({
getRequest: () => request,
}),
} as unknown as ExecutionContext;

expect(guard.canActivate(context)).toBe(true);
});
});
});
125 changes: 113 additions & 12 deletions src/common/guards/webhook-signature.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
Expand All @@ -13,14 +14,67 @@ import {
} from '../constants/headers';

/**
* Validates HMAC-SHA256 signature and timestamp freshness on incoming Stellar Horizon/Soroban webhook events.
* Resolves the HMAC secret for a given webhook integration.
* Returns the secret string, or undefined if no secret is configured.
*/
export type WebhookSecretResolver = (
request: Request,
integrationId?: string,
) => string | undefined | Promise<string | undefined>;

/**
* Configuration options for `WebhookSignatureGuard`.
*/
export interface WebhookSignatureGuardOptions {
/**
* Custom secret resolver for per-integration webhook secrets.
* When provided, this function is called first to resolve the secret.
* If it returns `undefined`, the guard falls back to global env-var secrets.
*/
secretResolver?: WebhookSecretResolver;
/**
* Timestamp tolerance in seconds (default: 300 = 5 minutes).
* Requests with timestamps older than this are rejected to prevent replay attacks.
*/
toleranceSeconds?: number;
}

/**
* Validates HMAC-SHA256 signature and timestamp freshness on incoming
* webhook events from external partner services and oracle providers.
*
* Signature calculation: HMAC-SHA256(timestamp + '.' + rawBody, secret)
*
* Security features:
* - Constant-time comparison via `crypto.timingSafeEqual` prevents timing attacks
* - Timestamp tolerance (default 5 min) prevents replay attacks
* - Configurable per-integration secret resolution for multi-tenant setups
* - Raw body buffering ensures accurate HMAC computation
*
* @example
* ```ts
* // Apply with default global secret:
* @UseGuards(WebhookSignatureGuard)
*
* // Apply with per-integration secret resolver:
* @UseGuards(new WebhookSignatureGuard(configService, {
* secretResolver: (req) => getIntegrationSecret(req.headers['x-integration-id']),
* }))
* ```
*/
@Injectable()
export class WebhookSignatureGuard implements CanActivate {
private readonly toleranceSeconds = 300; // 5 minutes
private readonly logger = new Logger(WebhookSignatureGuard.name);
private readonly toleranceSeconds: number;
private readonly secretResolver?: WebhookSecretResolver;

constructor(private readonly configService: ConfigService) {}
constructor(
private readonly configService: ConfigService,
options?: WebhookSignatureGuardOptions,
) {
this.toleranceSeconds = options?.toleranceSeconds ?? 300;
this.secretResolver = options?.secretResolver;
}

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
Expand All @@ -46,16 +100,14 @@ export class WebhookSignatureGuard implements CanActivate {
throw new UnauthorizedException('Webhook timestamp expired or out of tolerance');
}

const secret =
this.configService.get<string>('WEBHOOK_SECRET') ||
this.configService.get<string>('STELLAR_WEBHOOK_SECRET') ||
'astroid-webhook-secret-key-default';
// Resolve the secret: try per-integration resolver first, then fall back to global env vars.
const secret = this.resolveSecret(request);
if (!secret) {
this.logger.warn('No webhook secret configured; rejecting request');
throw new UnauthorizedException('No webhook signing secret configured');
}

const payload =
(request as Request & { rawBody?: Buffer | string }).rawBody ||
(typeof request.body === 'string'
? request.body
: JSON.stringify(request.body ?? {}));
const payload = this.extractPayload(request);

const expectedPayloadToSign = `${timestamp}.${payload}`;
const hmac = crypto.createHmac('sha256', secret);
Expand All @@ -74,4 +126,53 @@ export class WebhookSignatureGuard implements CanActivate {

return true;
}

/**
* Resolves the HMAC signing secret for the incoming request.
* Priority: per-integration resolver → WEBHOOK_SECRET → STELLAR_WEBHOOK_SECRET.
*/
private resolveSecret(request: Request): string | undefined {
// Per-integration secret resolver (synchronous or async)
const integrationId = request.headers['x-integration-id'] as string | undefined;
if (this.secretResolver) {
const resolved = this.secretResolver(request, integrationId);
// Handle both sync and async resolvers
if (resolved && typeof (resolved as Promise<string | undefined>).then === 'function') {
// Async resolver — store the promise for later use
// Note: canActivate doesn't support async in sync mode, so we
// log a warning. For async secret resolution, use the async guard variant.
this.logger.warn(
'Async secret resolver returned a Promise; use the async canActivate variant for async secret resolution',
);
} else if (resolved) {
return resolved as string;
}
}

// Global env-var fallback
return (
this.configService.get<string>('WEBHOOK_SECRET') ||
this.configService.get<string>('STELLAR_WEBHOOK_SECRET') ||
this.configService.get<string>('WEBHOOK_SIGNING_SECRET') ||
undefined
);
}

/**
* Extracts the request payload for HMAC computation.
* Prefers the raw body captured by `RawBodyMiddleware`, falling back to
* the parsed body if raw body is not available.
*/
private extractPayload(request: Request): string {
const rawBody = (request as Request & { rawBody?: Buffer | string }).rawBody;
if (rawBody !== undefined) {
return Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
}

if (typeof request.body === 'string') {
return request.body;
}

return JSON.stringify(request.body ?? {});
}
}
Loading
Loading