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
12 changes: 12 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ DATABASE_PASSWORD=your_database_password
DATABASE_USERNAME=your_database_username
DATABASE_PORT=5432
DATABASE_HOST=your_database_host
# Connection pool bounds (issue #1778) — tune per environment (e.g. lower in
# staging, higher under prod load) without a code change. Defaults match
# node-postgres's own defaults (min 2, max 10) when unset.
DB_POOL_MIN=2
DB_POOL_MAX=10

# Metrics endpoint access control (issue #1781)
# Shared secret the /metrics scrape endpoint requires as
# `Authorization: Bearer <token>` (the standard shape for a Prometheus
# bearer_token scrape config). Required outside local development — the
# endpoint refuses all requests in production if this is unset.
METRICS_ACCESS_TOKEN=change-me-in-every-environment

# Auth / JWT
# JWT_SECRET is read by src/auth/auth.module.ts.
Expand Down
14 changes: 13 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { BullModule } from '@nestjs/bull';
import { CacheModule } from '@nestjs/cache-manager';
import { ScheduleModule } from '@nestjs/schedule';
Expand All @@ -18,6 +18,7 @@ import { HealthModule } from './health/health.module';
import { MetricsService } from './common/metrics.service';
import { MetricsController } from './common/metrics.controller';
import { RequestContextMiddleware } from './common/request-context.middleware';
import { RequestDurationInterceptor } from './common/request-duration.interceptor';
import { TracingMiddleware } from './common/tracing.middleware';

@Module({
Expand Down Expand Up @@ -47,6 +48,13 @@ import { TracingMiddleware } from './common/tracing.middleware';
database: config.get<string>('DATABASE_NAME'),
autoLoadEntities: true,
synchronize: false,
// Env-configurable pool bounds (issue #1778) — see
// src/database/data-source.ts for the matching CLI/migration
// DataSource and the defaults these fall back to.
extra: {
min: config.get<number>('DB_POOL_MIN', 2),
max: config.get<number>('DB_POOL_MAX', 10),
},
}),
}),
// Backs the Soroban escrow submission queue (issue #1574) — see
Expand Down Expand Up @@ -88,6 +96,10 @@ import { TracingMiddleware } from './common/tracing.middleware';
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: RequestDurationInterceptor,
},
],
})
export class AppModule implements NestModule {
Expand Down
87 changes: 87 additions & 0 deletions backend/src/common/http-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { AllExceptionsFilter, redactSensitive } from './http-exception.filter';

describe('redactSensitive (issue #1779)', () => {
it('redacts key=value style secrets and tokens', () => {
expect(redactSensitive('auth failed: token=abc123.def456')).toBe(
'auth failed: token=[REDACTED]',
);
expect(redactSensitive('bad request, password: "hunter2"')).toBe(
'bad request, password=[REDACTED]',
);
expect(redactSensitive('Authorization=Bearer sk_live_abcdef')).toBe(
'Authorization=[REDACTED]',
);
});

it('redacts credentials inside a connection string, keeping scheme/host', () => {
expect(
redactSensitive(
'connect ECONNREFUSED postgres://appuser:s3cr3t@db.internal:5432/app',
),
).toBe('connect ECONNREFUSED postgres://[REDACTED]@db.internal:5432/app');
});

it('redacts a bare JWT-shaped token with no key name attached', () => {
const jwt =
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
expect(redactSensitive(`invalid session for ${jwt}`)).toBe(
'invalid session for [REDACTED]',
);
});

it('leaves ordinary messages untouched', () => {
expect(redactSensitive('Booking not found')).toBe('Booking not found');
});
});

describe('AllExceptionsFilter', () => {
function buildHost(): {
host: ArgumentsHost;
json: jest.Mock;
status: jest.Mock;
} {
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
const response = { status, getHeader: jest.fn() };
const request = { method: 'GET', url: '/wallets/link' };
const host = {
switchToHttp: () => ({
getResponse: () => response,
getRequest: () => request,
}),
} as unknown as ArgumentsHost;
return { host, json, status };
}

it('redacts sensitive content out of the client-facing message', () => {
const filter = new AllExceptionsFilter();
const { host, json, status } = buildHost();

filter.catch(
new HttpException(
'upstream rejected token=abc.def.ghi',
HttpStatus.BAD_GATEWAY,
),
host,
);

expect(status).toHaveBeenCalledWith(HttpStatus.BAD_GATEWAY);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'upstream rejected token=[REDACTED]',
}),
);
});

it('never leaks a raw non-HttpException message', () => {
const filter = new AllExceptionsFilter();
const { host, json } = buildHost();

filter.catch(new Error('secret=topsecret while connecting'), host);

expect(json).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Internal server error' }),
);
});
});
43 changes: 40 additions & 3 deletions backend/src/common/http-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@ import {
import { Request, Response } from 'express';
import { currentRequestId } from './request-context';

// Field names whose value should never reach the client, wherever they show
// up inside a `key: value`, `key=value`, or `key="value"` style substring of
// an error message (issue #1779) — e.g. a raw driver error that happens to
// echo back part of the connection options it was given.
const SENSITIVE_KEY_PATTERN =
/\b((?:api[_-]?)?(?:secret|token|password|passwd|pwd|auth(?:orization)?)[a-z_]*)\s*[:=]\s*("[^"]*"|'[^']*'|(?:Bearer\s+)?\S+)/gi;

// `scheme://user:pass@host` connection strings (Postgres, MySQL, MongoDB,
// Redis, AMQP, ...) — the credential portion is redacted, the scheme/host
// are left intact since they're useful for debugging and not secret.
const CONNECTION_STRING_PATTERN =
/([a-z][a-z0-9+.-]*:\/\/)[^:/?#\s]+:[^@/?#\s]+@/gi;

// A standalone bearer/JWT-shaped token (three dot-separated base64url
// segments), independent of any surrounding key name.
const JWT_PATTERN =
/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;

/**
* Strips tokens, secrets, and connection-string credentials out of a
* message before it can reach an HTTP response (issue #1779). Applied
* unconditionally (not just in non-dev environments) since a client-facing
* error payload should never carry these regardless of environment; the
* full, unredacted detail is still available server-side in the log line
* this filter writes below.
*/
export function redactSensitive(message: string): string {
return message
.replace(CONNECTION_STRING_PATTERN, '$1[REDACTED]@')
.replace(SENSITIVE_KEY_PATTERN, '$1=[REDACTED]')
.replace(JWT_PATTERN, '[REDACTED]');
}

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
Expand All @@ -21,15 +54,19 @@ export class AllExceptionsFilter implements ExceptionFilter {
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
const message = redactSensitive(
exception instanceof HttpException
? exception.message
: 'Internal server error';
: 'Internal server error',
);

// Stack traces (which can contain far more than the message, e.g. full
// query text) are only ever logged server-side here, never sent to the
// client in the response body below.
this.logger.error(
`${request.method} ${request.url} failed${
currentRequestId() ? ` requestId=${currentRequestId()}` : ''
}: ${exception instanceof Error ? exception.stack ?? exception.message : String(exception)}`,
}: ${exception instanceof Error ? (exception.stack ?? exception.message) : String(exception)}`,
);

response.status(status).json({
Expand Down
68 changes: 68 additions & 0 deletions backend/src/common/metrics-auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { MetricsAuthGuard } from './metrics-auth.guard';

describe('MetricsAuthGuard (issue #1781)', () => {
const ORIGINAL_ENV = { ...process.env };

afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});

function buildContext(authorizationHeader?: string): ExecutionContext {
const request = {
headers: authorizationHeader
? { authorization: authorizationHeader }
: {},
};
return {
switchToHttp: () => ({
getRequest: () => request,
}),
} as unknown as ExecutionContext;
}

it('allows a request bearing the correct token', () => {
process.env.METRICS_ACCESS_TOKEN = 'correct-horse-battery-staple';
const guard = new MetricsAuthGuard();

expect(
guard.canActivate(buildContext('Bearer correct-horse-battery-staple')),
).toBe(true);
});

it('rejects a request with the wrong token', () => {
process.env.METRICS_ACCESS_TOKEN = 'correct-horse-battery-staple';
const guard = new MetricsAuthGuard();

expect(() => guard.canActivate(buildContext('Bearer wrong-token'))).toThrow(
UnauthorizedException,
);
});

it('rejects a request with no Authorization header at all', () => {
process.env.METRICS_ACCESS_TOKEN = 'correct-horse-battery-staple';
const guard = new MetricsAuthGuard();

expect(() => guard.canActivate(buildContext())).toThrow(
UnauthorizedException,
);
});

it('refuses every request in production when no token is configured', () => {
delete process.env.METRICS_ACCESS_TOKEN;
process.env.NODE_ENV = 'production';
const guard = new MetricsAuthGuard();

expect(() => guard.canActivate(buildContext())).toThrow(
UnauthorizedException,
);
});

it('allows requests through in development when no token is configured', () => {
delete process.env.METRICS_ACCESS_TOKEN;
process.env.NODE_ENV = 'development';
const guard = new MetricsAuthGuard();

expect(guard.canActivate(buildContext())).toBe(true);
});
});
79 changes: 79 additions & 0 deletions backend/src/common/metrics-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { timingSafeEqual } from 'crypto';
import { Request } from 'express';

/**
* Restricts the Prometheus scrape endpoint (issue #1781). Previously
* MetricsController served request volumes and error rates by route with
* no access control at all, relying entirely on the operator to restrict
* it at the network level.
*
* Requires an `Authorization: Bearer <METRICS_ACCESS_TOKEN>` header
* matching the METRICS_ACCESS_TOKEN env var (the standard shape for a
* Prometheus `bearer_token` scrape config). Network-level restriction
* (e.g. only allowing the endpoint from an internal subnet) remains a
* valid, complementary option — this guard covers the case where that
* alone isn't in place.
*
* With no token configured: refuses every request outside development,
* since shipping to staging/prod without ever setting the token should
* fail closed rather than silently stay world-readable. In development
* it allows the request through (logging a one-time warning) so local
* `curl localhost:PORT/metrics` keeps working without extra setup.
*/
@Injectable()
export class MetricsAuthGuard implements CanActivate {
private readonly logger = new Logger(MetricsAuthGuard.name);
private warnedNoTokenConfigured = false;

canActivate(context: ExecutionContext): boolean {
const configuredToken = process.env.METRICS_ACCESS_TOKEN;

if (!configuredToken) {
if (process.env.NODE_ENV === 'production') {
throw new UnauthorizedException(
'Metrics endpoint is not configured for access (METRICS_ACCESS_TOKEN unset)',
);
}
if (!this.warnedNoTokenConfigured) {
this.warnedNoTokenConfigured = true;
this.logger.warn(
'METRICS_ACCESS_TOKEN is not set — /metrics is unauthenticated. ' +
'Set it before deploying outside local development.',
);
}
return true;
}

const request = context.switchToHttp().getRequest<Request>();
const presentedToken = this.extractToken(request);

if (!presentedToken || !this.matches(presentedToken, configuredToken)) {
throw new UnauthorizedException('Invalid or missing metrics token');
}

return true;
}

private extractToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}

/** Constant-time comparison so a wrong-length/wrong-value guess can't be
* timed to narrow down the correct token byte by byte. */
private matches(presented: string, configured: string): boolean {
const presentedBuf = Buffer.from(presented);
const configuredBuf = Buffer.from(configured);
if (presentedBuf.length !== configuredBuf.length) {
return false;
}
return timingSafeEqual(presentedBuf, configuredBuf);
}
}
10 changes: 10 additions & 0 deletions backend/src/common/metrics.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Test } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { MetricsController } from './metrics.controller';
import { MetricsService } from './metrics.service';
import { MetricsAuthGuard } from './metrics-auth.guard';

describe('MetricsController', () => {
let controller: MetricsController;
Expand Down Expand Up @@ -37,4 +39,12 @@ describe('MetricsController', () => {
expect(body).toContain('managehub_manual_review_queue_depth 3');
expect(service.renderPrometheus).toHaveBeenCalled();
});

it('is gated by MetricsAuthGuard (issue #1781)', () => {
const guards = new Reflector().get('__guards__', MetricsController) as
| unknown[]
| undefined;
expect(guards).toBeDefined();
expect(guards).toContainEqual(MetricsAuthGuard);
});
});
Loading
Loading