Protect Prometheus metrics in production (#576)

This commit is contained in:
Brad Groux 2026-06-04 14:11:59 -07:00 committed by GitHub
parent 14cb4b9339
commit a439791abb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 230 additions and 11 deletions

View file

@ -75,6 +75,11 @@ VERITAS_ADMIN_KEY=
# Days after which telemetry is compressed
# TELEMETRY_COMPRESS_DAYS=7
# Prometheus /metrics exposure
# In production, /metrics requires normal auth with telemetry:read unless one of these is set.
# PROMETHEUS_METRICS_TOKEN=
# PROMETHEUS_METRICS_PUBLIC=false
# ── External Services ────────────────────────────────────────
# Clawdbot gateway URL (default: http://127.0.0.1:18789)
# CLAWDBOT_GATEWAY=http://127.0.0.1:18789

View file

@ -532,6 +532,14 @@ All variables are set in `server/.env` (or passed as environment variables in Do
| `CSP_REPORT_ONLY` | `false` | Use Content-Security-Policy-Report-Only instead of enforcing |
| `CSP_REPORT_URI` | — | URL to receive CSP violation reports |
### Prometheus metrics
`GET /metrics` remains unauthenticated for local development. In production, use one of these explicit modes:
- `PROMETHEUS_METRICS_TOKEN=<secret>` and configure Prometheus to send `Authorization: Bearer <secret>`.
- A normal Veritas API key whose role or permissions include `telemetry:read`.
- `PROMETHEUS_METRICS_PUBLIC=true` only on a trusted private network where unauthenticated operational metrics are intentional.
### Data & Storage
| Variable | Default | Description |

View file

@ -578,6 +578,21 @@ All variables live in `server/.env` (copy from `server/.env.example`).
| `TRUST_PROXY` | — | Express proxy trust. Use `1` for single-hop (nginx/Caddy). `true` is blocked |
| `RATE_LIMIT_MAX` | `300` | Max API requests/minute/IP (localhost exempt) |
### Prometheus metrics
`GET /metrics` is public only in local development. In production, scrape it with one of these explicit configurations:
```yaml
scrape_configs:
- job_name: veritas-kanban
metrics_path: /metrics
static_configs:
- targets: ['veritas.example.com']
bearer_token: '<PROMETHEUS_METRICS_TOKEN>'
```
Set `PROMETHEUS_METRICS_TOKEN` on the Veritas server to the same secret, or use a normal Veritas API key with `telemetry:read` permission in the `Authorization: Bearer <key>` header. Only set `PROMETHEUS_METRICS_PUBLIC=true` for trusted private networks where unauthenticated metrics are intentional.
### Data & Storage
| Variable | Default | Description |

View file

@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
const originalEnv = { ...process.env };
async function buildApp(env: Record<string, string | undefined>) {
vi.resetModules();
process.env = { ...originalEnv };
delete process.env.NODE_ENV;
delete process.env.PROMETHEUS_METRICS_PUBLIC;
delete process.env.PROMETHEUS_METRICS_TOKEN;
delete process.env.VERITAS_ADMIN_KEY;
delete process.env.VERITAS_API_KEYS;
delete process.env.VERITAS_AUTH_ENABLED;
delete process.env.VERITAS_AUTH_LOCALHOST_BYPASS;
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
vi.doMock('../../config/security.js', () => ({
getSecurityConfig: vi.fn(() => ({
authEnabled: false,
passwordHash: null,
})),
getJwtSecret: vi.fn(() => 'test-jwt-secret'),
getValidJwtSecrets: vi.fn(() => ['test-jwt-secret']),
}));
vi.doMock('../../services/metrics/prometheus.js', () => ({
getPrometheusCollector: () => ({
scrape: () => '# HELP veritas_test_metric Test metric\nveritas_test_metric 1\n',
}),
}));
const { prometheusMetricsRouter } = await import('../../routes/prometheus.js');
const app = express();
app.use(prometheusMetricsRouter);
return app;
}
describe('Prometheus metrics route', () => {
afterEach(() => {
process.env = { ...originalEnv };
vi.resetModules();
vi.clearAllMocks();
});
it('allows unauthenticated local development scrapes', async () => {
const app = await buildApp({
NODE_ENV: 'development',
VERITAS_ADMIN_KEY: 'dev-admin-key',
});
const res = await request(app).get('/metrics');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/plain');
expect(res.text).toContain('veritas_test_metric 1');
});
it('requires authentication in production by default', async () => {
const app = await buildApp({
NODE_ENV: 'production',
VERITAS_ADMIN_KEY: 'production-admin-key',
});
const res = await request(app).get('/metrics');
expect(res.status).toBe(401);
expect(res.body.code).toBe('AUTH_REQUIRED');
});
it('allows production scrapes with an authenticated telemetry reader', async () => {
const app = await buildApp({
NODE_ENV: 'production',
VERITAS_ADMIN_KEY: 'production-admin-key',
VERITAS_API_KEYS: 'prometheus:metrics-reader:read-only',
});
const res = await request(app).get('/metrics').set('Authorization', 'Bearer metrics-reader');
expect(res.status).toBe(200);
expect(res.text).toContain('veritas_test_metric 1');
});
it('rejects production API keys without telemetry read permission', async () => {
const app = await buildApp({
NODE_ENV: 'production',
VERITAS_ADMIN_KEY: 'production-admin-key',
VERITAS_API_KEYS: 'agent:agent-key:agent',
});
const res = await request(app).get('/metrics').set('Authorization', 'Bearer agent-key');
expect(res.status).toBe(403);
expect(res.body.code).toBe('FORBIDDEN');
});
it('allows production scrapes with the dedicated Prometheus bearer token', async () => {
const app = await buildApp({
NODE_ENV: 'production',
VERITAS_ADMIN_KEY: 'production-admin-key',
PROMETHEUS_METRICS_TOKEN: 'prometheus-secret',
});
const res = await request(app).get('/metrics').set('Authorization', 'Bearer prometheus-secret');
expect(res.status).toBe(200);
expect(res.text).toContain('veritas_test_metric 1');
});
it('allows explicit public production scrapes only when opted in', async () => {
const app = await buildApp({
NODE_ENV: 'production',
VERITAS_ADMIN_KEY: 'production-admin-key',
PROMETHEUS_METRICS_PUBLIC: 'true',
});
const res = await request(app).get('/metrics');
expect(res.status).toBe(200);
expect(res.text).toContain('veritas_test_metric 1');
});
});

View file

@ -117,6 +117,12 @@ export const envSchema = z.object({
/** Days after which telemetry is compressed */
TELEMETRY_COMPRESS_DAYS: positiveIntString,
/** Allow unauthenticated Prometheus scraping in production */
PROMETHEUS_METRICS_PUBLIC: booleanString.default(false),
/** Dedicated bearer token for Prometheus scraping */
PROMETHEUS_METRICS_TOKEN: z.string().optional(),
// ── External Services ───────────────────────────────────────────────
/** Clawdbot gateway URL */
CLAWDBOT_GATEWAY: z.string().url().optional().default('http://127.0.0.1:18789'),

View file

@ -59,8 +59,8 @@ import { webhookN8nRouter } from './routes/webhook-n8n.js';
import { cspNonceMiddleware, injectCspNonceAttributes } from './middleware/csp-nonce.js';
import { apiDocsCspOverride, buildCspDirectives } from './config/csp.js';
import { healthRouter, apiHealthRouter, setHealthWss } from './routes/health.js';
import { getPrometheusCollector } from './services/metrics/prometheus.js';
import { metricsCollector } from './middleware/metrics-collector.js';
import { prometheusMetricsRouter } from './routes/prometheus.js';
import { getStorageTypeFromEnv, initStorage, shutdownStorage } from './storage/index.js';
import {
canReceiveWebSocketEvent,
@ -349,16 +349,10 @@ app.use('/health', healthRouter);
// Canonical VK API health signal (unauthenticated; used by dev tooling/watchdogs)
app.use('/api/health', apiHealthRouter);
// ============================================
// Prometheus Metrics (unauthenticated, for scraping)
// ============================================
// Returns metrics in Prometheus exposition text format.
// Placed before authentication so Prometheus can scrape without credentials.
app.get('/metrics', (_req, res) => {
const collector = getPrometheusCollector();
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
res.send(collector.scrape());
});
// Prometheus exposition endpoint. Public in local development; production
// requires normal auth with telemetry:read, PROMETHEUS_METRICS_TOKEN, or an
// explicit PROMETHEUS_METRICS_PUBLIC=true opt-in.
app.use(prometheusMetricsRouter);
// Metrics collection middleware — records per-request HTTP metrics.
// Placed after health/metrics endpoints so those aren't self-instrumented

View file

@ -0,0 +1,60 @@
import crypto from 'crypto';
import { Router, type NextFunction, type Request, type Response } from 'express';
import {
authenticate,
authorizePermission,
type AuthenticatedRequest,
} from '../middleware/auth.js';
import { getPrometheusCollector } from '../services/metrics/prometheus.js';
export const prometheusMetricsRouter = Router();
function envFlagEnabled(name: string): boolean {
return process.env[name]?.trim().toLowerCase() === 'true';
}
function publicMetricsAllowed(): boolean {
return process.env.NODE_ENV !== 'production' || envFlagEnabled('PROMETHEUS_METRICS_PUBLIC');
}
function constantTimeEquals(actual: string, expected: string): boolean {
const actualBuffer = Buffer.from(actual);
const expectedBuffer = Buffer.from(expected);
return (
actualBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(actualBuffer, expectedBuffer)
);
}
function hasMetricsBearerToken(req: Request): boolean {
const expectedToken = process.env.PROMETHEUS_METRICS_TOKEN?.trim();
if (!expectedToken) return false;
const authorization = req.headers.authorization;
if (!authorization?.startsWith('Bearer ')) return false;
return constantTimeEquals(authorization.slice(7).trim(), expectedToken);
}
function protectPrometheusMetrics(req: Request, res: Response, next: NextFunction): void {
if (publicMetricsAllowed() || hasMetricsBearerToken(req)) {
next();
return;
}
authenticate(req as AuthenticatedRequest, res, (authError?: unknown) => {
if (authError) {
next(authError);
return;
}
authorizePermission('telemetry:read')(req as AuthenticatedRequest, res, next);
});
}
prometheusMetricsRouter.get('/metrics', protectPrometheusMetrics, (_req, res) => {
const collector = getPrometheusCollector();
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
res.send(collector.scrape());
});