-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement MTA hooks for quota enforcement #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import { describe, it, expect, beforeEach } from 'vitest'; | ||
| import { createMock } from '@golevelup/ts-vitest'; | ||
| import { type ConfigService } from '@nestjs/config'; | ||
| import { UnauthorizedException, type ExecutionContext } from '@nestjs/common'; | ||
| import { MtaHooksAuthGuard } from './mta-hooks-auth.guard.js'; | ||
|
|
||
| const username = 'stalwart'; | ||
| const secret = 'secret'; | ||
|
|
||
| describe('MtaHooksAuthGuard', () => { | ||
| let guard: MtaHooksAuthGuard; | ||
|
|
||
| const contextWithAuth = (header?: string): ExecutionContext => | ||
| createMock<ExecutionContext>({ | ||
| switchToHttp: () => ({ | ||
| getRequest: () => ({ | ||
| headers: header ? { authorization: header } : {}, | ||
| }), | ||
| }), | ||
| }); | ||
|
|
||
| const basic = (username: string, secret: string): string => | ||
| `Basic ${Buffer.from(`${username}:${secret}`).toString('base64')}`; | ||
|
|
||
| beforeEach(() => { | ||
| const configService = createMock<ConfigService>(); | ||
| configService.getOrThrow.mockImplementation((key: string) => { | ||
| if (key === 'mtaHooks.username') return username; | ||
| if (key === 'mtaHooks.secret') return secret; | ||
| throw new Error(`unknown key: ${key}`); | ||
| }); | ||
| guard = new MtaHooksAuthGuard(configService); | ||
| }); | ||
|
|
||
| it('when credentials match, then allows the request', () => { | ||
| const context = contextWithAuth(basic(username, secret)); | ||
|
|
||
| expect(guard.canActivate(context)).toBe(true); | ||
| }); | ||
|
|
||
| it('when the secret is wrong, then throws Unauthorized', () => { | ||
| const context = contextWithAuth(basic(username, 'wrong')); | ||
|
|
||
| expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); | ||
| }); | ||
|
|
||
| it('when the username is wrong, then throws Unauthorized', () => { | ||
| const context = contextWithAuth(basic('wrong', secret)); | ||
|
|
||
| expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); | ||
| }); | ||
|
|
||
| it('when the Authorization header is missing, then throws Unauthorized', () => { | ||
| const context = contextWithAuth(undefined); | ||
|
|
||
| expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); | ||
| }); | ||
|
|
||
| it('when the scheme is not Basic, then throws Unauthorized', () => { | ||
| const context = contextWithAuth('Bearer some-token'); | ||
|
|
||
| expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); | ||
| }); | ||
|
|
||
| it('when the decoded credentials lack a colon separator, then throws Unauthorized', () => { | ||
| const context = contextWithAuth( | ||
| `Basic ${Buffer.from('nocolon').toString('base64')}`, | ||
| ); | ||
|
|
||
| expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { timingSafeEqual } from 'node:crypto'; | ||
| import { | ||
| CanActivate, | ||
| type ExecutionContext, | ||
| Injectable, | ||
| UnauthorizedException, | ||
| } from '@nestjs/common'; | ||
| import { ConfigService } from '@nestjs/config'; | ||
| import type { Request } from 'express'; | ||
|
|
||
| @Injectable() | ||
| export class MtaHooksAuthGuard implements CanActivate { | ||
| private readonly expectedUsername: string; | ||
| private readonly expectedSecret: string; | ||
|
|
||
| constructor(configService: ConfigService) { | ||
| this.expectedUsername = | ||
| configService.getOrThrow<string>('mtaHooks.username'); | ||
| this.expectedSecret = configService.getOrThrow<string>('mtaHooks.secret'); | ||
| } | ||
|
Comment on lines
+16
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Empty secret default enables authentication bypass when The config contract ( secret: process.env.MTA_HOOKS_SECRET ?? '',
Validate non-empty at construction time: 🔒️ Proposed fix constructor(configService: ConfigService) {
this.expectedUsername =
configService.getOrThrow<string>('mtaHooks.username');
this.expectedSecret = configService.getOrThrow<string>('mtaHooks.secret');
+ if (!this.expectedSecret) {
+ throw new Error(
+ 'mtaHooks.secret must be configured with a non-empty value',
+ );
+ }
}🤖 Prompt for AI Agents |
||
|
|
||
| canActivate(context: ExecutionContext): boolean { | ||
| const request = context.switchToHttp().getRequest<Request>(); | ||
| const header = request.headers.authorization ?? ''; | ||
|
|
||
| const [scheme, encoded] = header.split(' '); | ||
| if (scheme !== 'Basic' || !encoded) { | ||
| throw new UnauthorizedException('Missing or malformed Basic credentials'); | ||
| } | ||
|
|
||
| const decoded = Buffer.from(encoded, 'base64').toString('utf8'); | ||
| const separatorIndex = decoded.indexOf(':'); | ||
| if (separatorIndex === -1) { | ||
| throw new UnauthorizedException('Malformed Basic credentials'); | ||
| } | ||
|
|
||
| const username = decoded.slice(0, separatorIndex); | ||
| const secret = decoded.slice(separatorIndex + 1); | ||
|
|
||
| const usernameMatches = this.safeEqual(username, this.expectedUsername); | ||
| const secretMatches = this.safeEqual(secret, this.expectedSecret); | ||
| if (!usernameMatches || !secretMatches) { | ||
| throw new UnauthorizedException('Invalid MTA hook credentials'); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| private safeEqual(a: string, b: string): boolean { | ||
| const bufferA = Buffer.from(a, 'utf8'); | ||
| const bufferB = Buffer.from(b, 'utf8'); | ||
| if (bufferA.length !== bufferB.length) { | ||
| return false; | ||
| } | ||
| return timingSafeEqual(bufferA, bufferB); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { Body, Controller, Post, UseGuards } from '@nestjs/common'; | ||
| import { ApiBasicAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; | ||
| import { Public } from '../auth/decorators/public.decorator.js'; | ||
| import { MtaHooksAuthGuard } from './mta-hooks-auth.guard.js'; | ||
| import { MtaHooksService } from './mta-hooks.service.js'; | ||
| import type { MtaHookRequest, MtaHookResponse } from './mta-hooks.types.js'; | ||
|
|
||
| @ApiTags('MTA Hooks') | ||
| @ApiBasicAuth('mta-hooks') | ||
| @Public() | ||
| @UseGuards(MtaHooksAuthGuard) | ||
| @Controller('mta-hooks') | ||
| export class MtaHooksController { | ||
| constructor(private readonly mtaHooksService: MtaHooksService) {} | ||
|
|
||
| @Post('rcpt') | ||
| @ApiOperation({ | ||
| summary: 'RCPT-stage hook', | ||
| }) | ||
| rcpt(@Body() request: MtaHookRequest): Promise<MtaHookResponse> { | ||
| return this.mtaHooksService.handleRcpt(request); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { Module } from '@nestjs/common'; | ||
| import { AccountModule } from '../account/account.module.js'; | ||
| import { BridgeModule } from '../infrastructure/bridge/bridge.module.js'; | ||
| import { MtaHooksAuthGuard } from './mta-hooks-auth.guard.js'; | ||
| import { MtaHooksController } from './mta-hooks.controller.js'; | ||
| import { MtaHooksService } from './mta-hooks.service.js'; | ||
|
|
||
| @Module({ | ||
| imports: [AccountModule, BridgeModule], | ||
| controllers: [MtaHooksController], | ||
| providers: [MtaHooksService, MtaHooksAuthGuard], | ||
| }) | ||
| export class MtaHooksModule {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: internxt/mail-server
Length of output: 875
🏁 Script executed:
Repository: internxt/mail-server
Length of output: 5316
Reject empty
MTA_HOOKS_SECRETvaluesIf
MTA_HOOKS_SECRETis unset, the guard compares against'', so a Basic auth value ofstalwart:will satisfy the check and expose the MTA hooks endpoint. Fail startup or reject requests when the secret is empty.🤖 Prompt for AI Agents