Fix BMad adversarial security review findings
Some checks failed
CI — P2 Drift (Go + Node) / agent (push) Successful in 47s
CI — P2 Drift (Go + Node) / saas (push) Successful in 36s
CI — P3 Alert / test (push) Successful in 36s
CI — P4 Portal / build-push (push) Failing after 49s
CI — P5 Cost / build-push (push) Failing after 4s
CI — P6 Run / build-push (push) Failing after 4s
CI — P4 Portal / test (push) Successful in 35s
CI — P5 Cost / test (push) Successful in 40s
CI — P6 Run / saas (push) Successful in 36s
CI — P2 Drift (Go + Node) / build-push (push) Failing after 17s
CI — P3 Alert / build-push (push) Failing after 15s

Resolves 11 of the 13 findings:
- [CRITICAL] SQLi in RLS: replaced SET LOCAL with parameterized set_config()
- [CRITICAL] Rate Limiting: installed and registered @fastify/rate-limit in all 5 apps
- [CRITICAL] Invite Hijacking: added email verification check to invite lookup
- [HIGH] Webhook HMAC: added Fastify rawBody parser to fix JSON.stringify mangling
- [HIGH] TOCTOU Race: added FOR UPDATE to invite lookup
- [HIGH] Incident Race: replaced SELECT/INSERT with INSERT ... ON CONFLICT
- [MEDIUM] Grafana Timing Attack: replaced === with crypto.timingSafeEqual
- [MEDIUM] Insecure Defaults: added NODE_ENV production guard for JWT_SECRET
- [LOW] DB Privileges: tightened docker-init-db.sh grants (removed ALL PRIVILEGES)
- [LOW] Plaintext Invites: tokens are now hashed (SHA-256) before DB storage/lookup
- [LOW] Scrypt: increased N parameter to 65536 for stronger password hashing

Note:
- Finding #4 (Fragmented Identity) requires a unified auth database architecture.
- Finding #8 (getPoolForAuth) is an accepted tradeoff to keep auth middleware clean.
This commit is contained in:
2026-03-03 00:14:39 +00:00
parent eb953cdea5
commit 5792f95d7c
34 changed files with 379 additions and 129 deletions

View File

@@ -12,6 +12,7 @@
"@aws-sdk/client-sqs": "^3.600.0", "@aws-sdk/client-sqs": "^3.600.0",
"@fastify/cors": "^9.0.0", "@fastify/cors": "^9.0.0",
"@fastify/helmet": "^11.1.0", "@fastify/helmet": "^11.1.0",
"@fastify/rate-limit": "^9.1.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"drizzle-orm": "^0.31.0", "drizzle-orm": "^0.31.0",
"fastify": "^4.28.0", "fastify": "^4.28.0",
@@ -2068,6 +2069,17 @@
"fast-deep-equal": "^3.1.3" "fast-deep-equal": "^3.1.3"
} }
}, },
"node_modules/@fastify/rate-limit": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz",
"integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==",
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.1",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.3.1"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2146,6 +2158,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",

View File

@@ -12,31 +12,31 @@
"lint": "eslint src/ tests/" "lint": "eslint src/ tests/"
}, },
"dependencies": { "dependencies": {
"fastify": "^4.28.0", "@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/client-sqs": "^3.600.0",
"@fastify/cors": "^9.0.0", "@fastify/cors": "^9.0.0",
"@fastify/rate-limit": "^9.1.0",
"@fastify/helmet": "^11.1.0", "@fastify/helmet": "^11.1.0",
"pg": "^8.12.0", "@fastify/rate-limit": "^9.1.0",
"drizzle-orm": "^0.31.0",
"ioredis": "^5.4.0",
"zod": "^3.23.0",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"drizzle-orm": "^0.31.0",
"fastify": "^4.28.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"pino": "^9.1.0", "pino": "^9.1.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"@aws-sdk/client-sqs": "^3.600.0", "zod": "^3.23.0"
"@aws-sdk/client-s3": "^3.600.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.0", "@types/bcryptjs": "^2.4.6",
"tsx": "^4.15.0", "@types/jsonwebtoken": "^9.0.6",
"vitest": "^1.6.0",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/bcryptjs": "^2.4.6",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"drizzle-kit": "^0.22.0", "drizzle-kit": "^0.22.0",
"eslint": "^9.5.0" "eslint": "^9.5.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0",
"vitest": "^1.6.0"
} }
} }

View File

@@ -96,7 +96,7 @@ export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h
async function hashPassword(password: string): Promise<string> { async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(`${salt}:${derived.toString('hex')}`); resolve(`${salt}:${derived.toString('hex')}`);
}); });
@@ -106,7 +106,7 @@ async function hashPassword(password: string): Promise<string> {
async function verifyPassword(password: string, hash: string): Promise<boolean> { async function verifyPassword(password: string, hash: string): Promise<boolean> {
const [salt, key] = hash.split(':'); const [salt, key] = hash.split(':');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived)); resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived));
}); });
@@ -178,9 +178,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
let role: string; let role: string;
if (body.invite_token) { if (body.invite_token) {
const tokenHash = crypto.createHash('sha256').update(body.invite_token).digest('hex');
const invite = await client.query( const invite = await client.query(
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, `SELECT id, tenant_id, email, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1 FOR UPDATE`,
[body.invite_token], [tokenHash],
); );
if (!invite.rows[0]) { if (!invite.rows[0]) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -195,6 +196,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Invite expired' }); return reply.status(400).send({ error: 'Invite expired' });
} }
if (inv.email && inv.email.toLowerCase() !== body.email.toLowerCase()) {
await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Email does not match invite' });
}
tenantId = inv.tenant_id; tenantId = inv.tenant_id;
role = inv.role; role = inv.role;
@@ -278,11 +283,12 @@ export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: str
const body = inviteSchema.parse(req.body); const body = inviteSchema.parse(req.body);
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
const inviteTokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await pool.query( const result = await pool.query(
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING expires_at`, RETURNING expires_at`,
[tenantId, body.email, body.role, token, userId], [tenantId, body.email, body.role, inviteTokenHash, userId],
); );
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });

View File

@@ -16,7 +16,7 @@ export function createPool(connectionString: string): pg.Pool {
*/ */
export async function setTenantContext(client: pg.PoolClient, tenantId: string): Promise<void> { export async function setTenantContext(client: pg.PoolClient, tenantId: string): Promise<void> {
if (!/^[0-9a-f-]{36}$/i.test(tenantId)) throw new Error('Invalid tenant ID'); if (!/^[0-9a-f-]{36}$/i.test(tenantId)) throw new Error('Invalid tenant ID');
await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`); await client.query('SELECT set_config($1, $2, true)', ['app.tenant_id', tenantId]);
} }
/** /**

View File

@@ -1,6 +1,7 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import helmet from '@fastify/helmet'; import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import { config } from './config/index.js'; import { config } from './config/index.js';
import { createPool } from './data/db.js'; import { createPool } from './data/db.js';
import { createRedis } from './data/redis.js'; import { createRedis } from './data/redis.js';
@@ -17,6 +18,7 @@ const app = Fastify({
async function start() { async function start() {
await app.register(cors, { origin: config.corsOrigin }); await app.register(cors, { origin: config.corsOrigin });
await app.register(helmet); await app.register(helmet);
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' });
// Data layer // Data layer
const pool = createPool(config.databaseUrl); const pool = createPool(config.databaseUrl);

View File

@@ -12,6 +12,7 @@
"@aws-sdk/client-sqs": "^3.600.0", "@aws-sdk/client-sqs": "^3.600.0",
"@fastify/cors": "^9.0.0", "@fastify/cors": "^9.0.0",
"@fastify/helmet": "^11.1.0", "@fastify/helmet": "^11.1.0",
"@fastify/rate-limit": "^9.1.0",
"fastify": "^4.28.0", "fastify": "^4.28.0",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -1628,6 +1629,17 @@
"fast-deep-equal": "^3.1.3" "fast-deep-equal": "^3.1.3"
} }
}, },
"node_modules/@fastify/rate-limit": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz",
"integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==",
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.1",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.3.1"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1706,6 +1718,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",

View File

@@ -11,27 +11,27 @@
"lint": "eslint src/ tests/" "lint": "eslint src/ tests/"
}, },
"dependencies": { "dependencies": {
"fastify": "^4.28.0", "@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/client-sqs": "^3.600.0",
"@fastify/cors": "^9.0.0", "@fastify/cors": "^9.0.0",
"@fastify/rate-limit": "^9.1.0",
"@fastify/helmet": "^11.1.0", "@fastify/helmet": "^11.1.0",
"pg": "^8.12.0", "@fastify/rate-limit": "^9.1.0",
"fastify": "^4.28.0",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"zod": "^3.23.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"pino": "^9.1.0", "pino": "^9.1.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"@aws-sdk/client-sqs": "^3.600.0", "zod": "^3.23.0"
"@aws-sdk/client-s3": "^3.600.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.0", "@types/jsonwebtoken": "^9.0.6",
"tsx": "^4.15.0",
"vitest": "^1.6.0",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"eslint": "^9.5.0" "eslint": "^9.5.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0",
"vitest": "^1.6.0"
} }
} }

View File

@@ -1,4 +1,5 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import crypto from 'node:crypto';
import pino from 'pino'; import pino from 'pino';
import { import {
validateDatadogHmac, validateDatadogHmac,
@@ -12,11 +13,20 @@ const logger = pino({ name: 'api-webhooks' });
const REDIS_QUEUE = 'dd0c:webhooks:incoming'; const REDIS_QUEUE = 'dd0c:webhooks:incoming';
export function registerWebhookRoutes(app: FastifyInstance) { export function registerWebhookRoutes(app: FastifyInstance) {
// Add raw body content type parser for HMAC validation
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
(req as any).rawBody = body;
try {
done(null, JSON.parse(body.toString()));
} catch (err) {
done(err as Error, undefined);
}
});
// Datadog webhook // Datadog webhook
app.post('/webhooks/datadog/:tenantSlug', async (req, reply) => { app.post('/webhooks/datadog/:tenantSlug', async (req, reply) => {
const { tenantSlug } = req.params as { tenantSlug: string }; const { tenantSlug } = req.params as { tenantSlug: string };
const body = req.body as any; const rawBody = ((req as any).rawBody as Buffer).toString();
const rawBody = JSON.stringify(body);
const secret = await getWebhookSecret(tenantSlug, 'datadog'); const secret = await getWebhookSecret(tenantSlug, 'datadog');
if (!secret) return reply.status(404).send({ error: 'Unknown tenant' }); if (!secret) return reply.status(404).send({ error: 'Unknown tenant' });
@@ -35,7 +45,7 @@ export function registerWebhookRoutes(app: FastifyInstance) {
await redis.lpush(REDIS_QUEUE, JSON.stringify({ await redis.lpush(REDIS_QUEUE, JSON.stringify({
provider: 'datadog', provider: 'datadog',
tenantId: secret.tenantId, tenantId: secret.tenantId,
payload: body, payload: req.body,
receivedAt: Date.now(), receivedAt: Date.now(),
})); }));
return reply.status(202).send({ status: 'accepted' }); return reply.status(202).send({ status: 'accepted' });
@@ -44,8 +54,7 @@ export function registerWebhookRoutes(app: FastifyInstance) {
// PagerDuty webhook // PagerDuty webhook
app.post('/webhooks/pagerduty/:tenantSlug', async (req, reply) => { app.post('/webhooks/pagerduty/:tenantSlug', async (req, reply) => {
const { tenantSlug } = req.params as { tenantSlug: string }; const { tenantSlug } = req.params as { tenantSlug: string };
const body = req.body as any; const rawBody = ((req as any).rawBody as Buffer).toString();
const rawBody = JSON.stringify(body);
const secret = await getWebhookSecret(tenantSlug, 'pagerduty'); const secret = await getWebhookSecret(tenantSlug, 'pagerduty');
if (!secret) return reply.status(404).send({ error: 'Unknown tenant' }); if (!secret) return reply.status(404).send({ error: 'Unknown tenant' });
@@ -63,7 +72,7 @@ export function registerWebhookRoutes(app: FastifyInstance) {
await redis.lpush(REDIS_QUEUE, JSON.stringify({ await redis.lpush(REDIS_QUEUE, JSON.stringify({
provider: 'pagerduty', provider: 'pagerduty',
tenantId: secret.tenantId, tenantId: secret.tenantId,
payload: body, payload: req.body,
receivedAt: Date.now(), receivedAt: Date.now(),
})); }));
return reply.status(202).send({ status: 'accepted' }); return reply.status(202).send({ status: 'accepted' });
@@ -72,8 +81,7 @@ export function registerWebhookRoutes(app: FastifyInstance) {
// OpsGenie webhook // OpsGenie webhook
app.post('/webhooks/opsgenie/:tenantSlug', async (req, reply) => { app.post('/webhooks/opsgenie/:tenantSlug', async (req, reply) => {
const { tenantSlug } = req.params as { tenantSlug: string }; const { tenantSlug } = req.params as { tenantSlug: string };
const body = req.body as any; const rawBody = ((req as any).rawBody as Buffer).toString();
const rawBody = JSON.stringify(body);
const secret = await getWebhookSecret(tenantSlug, 'opsgenie'); const secret = await getWebhookSecret(tenantSlug, 'opsgenie');
if (!secret) return reply.status(404).send({ error: 'Unknown tenant' }); if (!secret) return reply.status(404).send({ error: 'Unknown tenant' });
@@ -91,29 +99,30 @@ export function registerWebhookRoutes(app: FastifyInstance) {
await redis.lpush(REDIS_QUEUE, JSON.stringify({ await redis.lpush(REDIS_QUEUE, JSON.stringify({
provider: 'opsgenie', provider: 'opsgenie',
tenantId: secret.tenantId, tenantId: secret.tenantId,
payload: body, payload: req.body,
receivedAt: Date.now(), receivedAt: Date.now(),
})); }));
return reply.status(202).send({ status: 'accepted' }); return reply.status(202).send({ status: 'accepted' });
}); });
// Grafana webhook (token-based auth, no HMAC) // Grafana webhook (token-based auth — use timingSafeEqual)
app.post('/webhooks/grafana/:tenantSlug', async (req, reply) => { app.post('/webhooks/grafana/:tenantSlug', async (req, reply) => {
const { tenantSlug } = req.params as { tenantSlug: string }; const { tenantSlug } = req.params as { tenantSlug: string };
const body = req.body as any;
const secret = await getWebhookSecret(tenantSlug, 'grafana'); const secret = await getWebhookSecret(tenantSlug, 'grafana');
if (!secret) return reply.status(404).send({ error: 'Unknown tenant' }); if (!secret) return reply.status(404).send({ error: 'Unknown tenant' });
const token = req.headers['authorization']?.replace('Bearer ', ''); const token = req.headers['authorization']?.replace('Bearer ', '') || '';
if (token !== secret.secret) { const tokenBuf = Buffer.from(token);
const secretBuf = Buffer.from(secret.secret);
if (tokenBuf.length !== secretBuf.length || !crypto.timingSafeEqual(tokenBuf, secretBuf)) {
return reply.status(401).send({ error: 'Invalid token' }); return reply.status(401).send({ error: 'Invalid token' });
} }
await redis.lpush(REDIS_QUEUE, JSON.stringify({ await redis.lpush(REDIS_QUEUE, JSON.stringify({
provider: 'grafana', provider: 'grafana',
tenantId: secret.tenantId, tenantId: secret.tenantId,
payload: body, payload: req.body,
receivedAt: Date.now(), receivedAt: Date.now(),
})); }));
return reply.status(202).send({ status: 'accepted' }); return reply.status(202).send({ status: 'accepted' });

View File

@@ -96,7 +96,7 @@ export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h
async function hashPassword(password: string): Promise<string> { async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(`${salt}:${derived.toString('hex')}`); resolve(`${salt}:${derived.toString('hex')}`);
}); });
@@ -106,7 +106,7 @@ async function hashPassword(password: string): Promise<string> {
async function verifyPassword(password: string, hash: string): Promise<boolean> { async function verifyPassword(password: string, hash: string): Promise<boolean> {
const [salt, key] = hash.split(':'); const [salt, key] = hash.split(':');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived)); resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived));
}); });
@@ -178,9 +178,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
let role: string; let role: string;
if (body.invite_token) { if (body.invite_token) {
const tokenHash = crypto.createHash('sha256').update(body.invite_token).digest('hex');
const invite = await client.query( const invite = await client.query(
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, `SELECT id, tenant_id, email, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1 FOR UPDATE`,
[body.invite_token], [tokenHash],
); );
if (!invite.rows[0]) { if (!invite.rows[0]) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -195,6 +196,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Invite expired' }); return reply.status(400).send({ error: 'Invite expired' });
} }
if (inv.email && inv.email.toLowerCase() !== body.email.toLowerCase()) {
await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Email does not match invite' });
}
tenantId = inv.tenant_id; tenantId = inv.tenant_id;
role = inv.role; role = inv.role;
@@ -278,11 +283,12 @@ export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: str
const body = inviteSchema.parse(req.body); const body = inviteSchema.parse(req.body);
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
const inviteTokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await pool.query( const result = await pool.query(
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING expires_at`, RETURNING expires_at`,
[tenantId, body.email, body.role, token, userId], [tenantId, body.email, body.role, inviteTokenHash, userId],
); );
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });

View File

@@ -9,5 +9,9 @@ const envSchema = z.object({
LOG_LEVEL: z.string().default('info'), LOG_LEVEL: z.string().default('info'),
}); });
export const config = envSchema.parse(process.env); const parsed = envSchema.parse(process.env);
if (process.env.NODE_ENV === 'production' && parsed.JWT_SECRET.includes('change-me')) {
throw new Error('FATAL: JWT_SECRET must be changed in production');
}
export const config = parsed;
export type Config = z.infer<typeof envSchema>; export type Config = z.infer<typeof envSchema>;

View File

@@ -14,7 +14,7 @@ export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`); await client.query('SELECT set_config($1, $2, true)', ['app.tenant_id', tenantId]);
const result = await fn(client); const result = await fn(client);
await client.query('COMMIT'); await client.query('COMMIT');
return result; return result;

View File

@@ -1,6 +1,7 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import helmet from '@fastify/helmet'; import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import pino from 'pino'; import pino from 'pino';
import { config } from './config/index.js'; import { config } from './config/index.js';
import { getPoolForAuth } from './data/db.js'; import { getPoolForAuth } from './data/db.js';
@@ -17,6 +18,7 @@ const app = Fastify({ logger: true });
await app.register(cors, { origin: config.CORS_ORIGIN }); await app.register(cors, { origin: config.CORS_ORIGIN });
await app.register(helmet); await app.register(helmet);
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' });
const pool = getPoolForAuth(); const pool = getPoolForAuth();
decorateAuth(app); decorateAuth(app);

View File

@@ -73,7 +73,8 @@ async function processWebhook(item: QueuedWebhook): Promise<void> {
const existing = await client.query( const existing = await client.query(
`SELECT id, alert_count FROM incidents `SELECT id, alert_count FROM incidents
WHERE fingerprint = $1 AND status IN ('open', 'acknowledged') WHERE fingerprint = $1 AND status IN ('open', 'acknowledged')
ORDER BY created_at DESC LIMIT 1`, ORDER BY created_at DESC LIMIT 1
FOR UPDATE`,
[alert.fingerprint], [alert.fingerprint],
); );
@@ -87,6 +88,8 @@ async function processWebhook(item: QueuedWebhook): Promise<void> {
const incident = await client.query( const incident = await client.query(
`INSERT INTO incidents (tenant_id, incident_key, fingerprint, service, title, severity, alert_count, first_alert_at, last_alert_at) `INSERT INTO incidents (tenant_id, incident_key, fingerprint, service, title, severity, alert_count, first_alert_at, last_alert_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now()) VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now())
ON CONFLICT (fingerprint) WHERE status IN ('open', 'acknowledged')
DO UPDATE SET alert_count = incidents.alert_count + 1, last_alert_at = now()
RETURNING id`, RETURNING id`,
[item.tenantId, `inc_${crypto.randomUUID().slice(0, 8)}`, alert.fingerprint, alert.service ?? 'unknown', alert.title, alert.severity], [item.tenantId, `inc_${crypto.randomUUID().slice(0, 8)}`, alert.fingerprint, alert.service ?? 'unknown', alert.title, alert.severity],
); );

View File

@@ -14,6 +14,7 @@
"@aws-sdk/client-rds": "^3.600.0", "@aws-sdk/client-rds": "^3.600.0",
"@fastify/cors": "^9.0.0", "@fastify/cors": "^9.0.0",
"@fastify/helmet": "^11.1.0", "@fastify/helmet": "^11.1.0",
"@fastify/rate-limit": "^9.1.0",
"@octokit/rest": "^20.1.0", "@octokit/rest": "^20.1.0",
"fastify": "^4.28.0", "fastify": "^4.28.0",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
@@ -1522,6 +1523,17 @@
"fast-deep-equal": "^3.1.3" "fast-deep-equal": "^3.1.3"
} }
}, },
"node_modules/@fastify/rate-limit": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz",
"integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==",
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.1",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.3.1"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1600,6 +1612,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@octokit/auth-token": { "node_modules/@octokit/auth-token": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz",

View File

@@ -11,31 +11,31 @@
"lint": "eslint src/ tests/" "lint": "eslint src/ tests/"
}, },
"dependencies": { "dependencies": {
"fastify": "^4.28.0",
"@fastify/cors": "^9.0.0",
"@fastify/rate-limit": "^9.1.0",
"@fastify/helmet": "^11.1.0",
"pg": "^8.12.0",
"ioredis": "^5.4.0",
"meilisearch": "^0.41.0",
"zod": "^3.23.0",
"jsonwebtoken": "^9.0.2",
"pino": "^9.1.0",
"uuid": "^9.0.1",
"@aws-sdk/client-organizations": "^3.600.0",
"@aws-sdk/client-ecs": "^3.600.0", "@aws-sdk/client-ecs": "^3.600.0",
"@aws-sdk/client-lambda": "^3.600.0", "@aws-sdk/client-lambda": "^3.600.0",
"@aws-sdk/client-organizations": "^3.600.0",
"@aws-sdk/client-rds": "^3.600.0", "@aws-sdk/client-rds": "^3.600.0",
"@octokit/rest": "^20.1.0" "@fastify/cors": "^9.0.0",
"@fastify/helmet": "^11.1.0",
"@fastify/rate-limit": "^9.1.0",
"@octokit/rest": "^20.1.0",
"fastify": "^4.28.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"meilisearch": "^0.41.0",
"pg": "^8.12.0",
"pino": "^9.1.0",
"uuid": "^9.0.1",
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.0", "@types/jsonwebtoken": "^9.0.6",
"tsx": "^4.15.0",
"vitest": "^1.6.0",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"eslint": "^9.5.0" "eslint": "^9.5.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0",
"vitest": "^1.6.0"
} }
} }

View File

@@ -96,7 +96,7 @@ export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h
async function hashPassword(password: string): Promise<string> { async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(`${salt}:${derived.toString('hex')}`); resolve(`${salt}:${derived.toString('hex')}`);
}); });
@@ -106,7 +106,7 @@ async function hashPassword(password: string): Promise<string> {
async function verifyPassword(password: string, hash: string): Promise<boolean> { async function verifyPassword(password: string, hash: string): Promise<boolean> {
const [salt, key] = hash.split(':'); const [salt, key] = hash.split(':');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived)); resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived));
}); });
@@ -178,9 +178,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
let role: string; let role: string;
if (body.invite_token) { if (body.invite_token) {
const tokenHash = crypto.createHash('sha256').update(body.invite_token).digest('hex');
const invite = await client.query( const invite = await client.query(
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, `SELECT id, tenant_id, email, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1 FOR UPDATE`,
[body.invite_token], [tokenHash],
); );
if (!invite.rows[0]) { if (!invite.rows[0]) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -195,6 +196,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Invite expired' }); return reply.status(400).send({ error: 'Invite expired' });
} }
if (inv.email && inv.email.toLowerCase() !== body.email.toLowerCase()) {
await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Email does not match invite' });
}
tenantId = inv.tenant_id; tenantId = inv.tenant_id;
role = inv.role; role = inv.role;
@@ -279,11 +284,12 @@ export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: str
const body = inviteSchema.parse(req.body); const body = inviteSchema.parse(req.body);
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
const inviteTokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await pool.query( const result = await pool.query(
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING expires_at`, RETURNING expires_at`,
[tenantId, body.email, body.role, token, userId], [tenantId, body.email, body.role, inviteTokenHash, userId],
); );
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });

View File

@@ -11,5 +11,9 @@ const envSchema = z.object({
LOG_LEVEL: z.string().default('info'), LOG_LEVEL: z.string().default('info'),
}); });
export const config = envSchema.parse(process.env); const parsed = envSchema.parse(process.env);
if (process.env.NODE_ENV === 'production' && parsed.JWT_SECRET.includes('change-me')) {
throw new Error('FATAL: JWT_SECRET must be changed in production');
}
export const config = parsed;
export type Config = z.infer<typeof envSchema>; export type Config = z.infer<typeof envSchema>;

View File

@@ -14,7 +14,7 @@ export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`); await client.query('SELECT set_config($1, $2, true)', ['app.tenant_id', tenantId]);
const result = await fn(client); const result = await fn(client);
await client.query('COMMIT'); await client.query('COMMIT');
return result; return result;

View File

@@ -1,6 +1,7 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import helmet from '@fastify/helmet'; import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import pino from 'pino'; import pino from 'pino';
import { config } from './config/index.js'; import { config } from './config/index.js';
import { getPoolForAuth } from './data/db.js'; import { getPoolForAuth } from './data/db.js';
@@ -15,6 +16,7 @@ const app = Fastify({ logger: true });
await app.register(cors, { origin: config.CORS_ORIGIN }); await app.register(cors, { origin: config.CORS_ORIGIN });
await app.register(helmet); await app.register(helmet);
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' });
const pool = getPoolForAuth(); const pool = getPoolForAuth();
decorateAuth(app); decorateAuth(app);

View File

@@ -13,6 +13,7 @@
"@aws-sdk/client-dynamodb": "^3.600.0", "@aws-sdk/client-dynamodb": "^3.600.0",
"@aws-sdk/lib-dynamodb": "^3.600.0", "@aws-sdk/lib-dynamodb": "^3.600.0",
"@fastify/cors": "^9.0.0", "@fastify/cors": "^9.0.0",
"@fastify/rate-limit": "^9.1.0",
"@slack/web-api": "^7.1.0", "@slack/web-api": "^7.1.0",
"fastify": "^4.28.0", "fastify": "^4.28.0",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
@@ -1506,6 +1507,17 @@
"fast-deep-equal": "^3.1.3" "fast-deep-equal": "^3.1.3"
} }
}, },
"node_modules/@fastify/rate-limit": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz",
"integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==",
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.1",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.3.1"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1584,6 +1596,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",

View File

@@ -11,30 +11,30 @@
"lint": "eslint src/ tests/" "lint": "eslint src/ tests/"
}, },
"dependencies": { "dependencies": {
"fastify": "^4.28.0",
"@fastify/cors": "^9.0.0",
"@fastify/rate-limit": "^9.1.0",
"pg": "^8.12.0",
"ioredis": "^5.4.0",
"zod": "^3.23.0",
"jsonwebtoken": "^9.0.2",
"pino": "^9.1.0",
"uuid": "^9.0.1",
"@aws-sdk/client-cost-explorer": "^3.600.0",
"@aws-sdk/client-cloudtrail": "^3.600.0", "@aws-sdk/client-cloudtrail": "^3.600.0",
"@aws-sdk/client-cost-explorer": "^3.600.0",
"@aws-sdk/client-dynamodb": "^3.600.0", "@aws-sdk/client-dynamodb": "^3.600.0",
"@aws-sdk/lib-dynamodb": "^3.600.0", "@aws-sdk/lib-dynamodb": "^3.600.0",
"@slack/web-api": "^7.1.0" "@fastify/cors": "^9.0.0",
"@fastify/rate-limit": "^9.1.0",
"@slack/web-api": "^7.1.0",
"fastify": "^4.28.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"pino": "^9.1.0",
"uuid": "^9.0.1",
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.0", "@types/jsonwebtoken": "^9.0.6",
"tsx": "^4.15.0",
"vitest": "^1.6.0",
"fast-check": "^3.19.0",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"eslint": "^9.5.0" "eslint": "^9.5.0",
"fast-check": "^3.19.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0",
"vitest": "^1.6.0"
} }
} }

View File

@@ -96,7 +96,7 @@ export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h
async function hashPassword(password: string): Promise<string> { async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(`${salt}:${derived.toString('hex')}`); resolve(`${salt}:${derived.toString('hex')}`);
}); });
@@ -106,7 +106,7 @@ async function hashPassword(password: string): Promise<string> {
async function verifyPassword(password: string, hash: string): Promise<boolean> { async function verifyPassword(password: string, hash: string): Promise<boolean> {
const [salt, key] = hash.split(':'); const [salt, key] = hash.split(':');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived)); resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived));
}); });
@@ -178,9 +178,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
let role: string; let role: string;
if (body.invite_token) { if (body.invite_token) {
const tokenHash = crypto.createHash('sha256').update(body.invite_token).digest('hex');
const invite = await client.query( const invite = await client.query(
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, `SELECT id, tenant_id, email, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1 FOR UPDATE`,
[body.invite_token], [tokenHash],
); );
if (!invite.rows[0]) { if (!invite.rows[0]) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -195,6 +196,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Invite expired' }); return reply.status(400).send({ error: 'Invite expired' });
} }
if (inv.email && inv.email.toLowerCase() !== body.email.toLowerCase()) {
await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Email does not match invite' });
}
tenantId = inv.tenant_id; tenantId = inv.tenant_id;
role = inv.role; role = inv.role;
@@ -278,11 +283,12 @@ export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: str
const body = inviteSchema.parse(req.body); const body = inviteSchema.parse(req.body);
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
const inviteTokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await pool.query( const result = await pool.query(
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING expires_at`, RETURNING expires_at`,
[tenantId, body.email, body.role, token, userId], [tenantId, body.email, body.role, inviteTokenHash, userId],
); );
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });

View File

@@ -10,5 +10,9 @@ const envSchema = z.object({
ANOMALY_THRESHOLD: z.coerce.number().default(50), ANOMALY_THRESHOLD: z.coerce.number().default(50),
}); });
export const config = envSchema.parse(process.env); const parsed = envSchema.parse(process.env);
if (process.env.NODE_ENV === 'production' && parsed.JWT_SECRET.includes('change-me')) {
throw new Error('FATAL: JWT_SECRET must be changed in production');
}
export const config = parsed;
export type Config = z.infer<typeof envSchema>; export type Config = z.infer<typeof envSchema>;

View File

@@ -14,7 +14,7 @@ export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`); await client.query('SELECT set_config($1, $2, true)', ['app.tenant_id', tenantId]);
const result = await fn(client); const result = await fn(client);
await client.query('COMMIT'); await client.query('COMMIT');
return result; return result;

View File

@@ -1,5 +1,6 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import pino from 'pino'; import pino from 'pino';
import { config } from './config/index.js'; import { config } from './config/index.js';
import { getPoolForAuth } from './data/db.js'; import { getPoolForAuth } from './data/db.js';
@@ -14,6 +15,7 @@ const logger = pino({ name: 'dd0c-cost', level: config.LOG_LEVEL });
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
await app.register(cors, { origin: config.CORS_ORIGIN }); await app.register(cors, { origin: config.CORS_ORIGIN });
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' });
const pool = getPoolForAuth(); const pool = getPoolForAuth();
decorateAuth(app); decorateAuth(app);

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@fastify/cors": "^9.0.0", "@fastify/cors": "^9.0.0",
"@fastify/helmet": "^11.1.0", "@fastify/helmet": "^11.1.0",
"@fastify/rate-limit": "^9.1.0",
"@fastify/websocket": "^10.0.0", "@fastify/websocket": "^10.0.0",
"@slack/bolt": "^3.19.0", "@slack/bolt": "^3.19.0",
"@slack/web-api": "^7.1.0", "@slack/web-api": "^7.1.0",
@@ -712,6 +713,17 @@
"fast-deep-equal": "^3.1.3" "fast-deep-equal": "^3.1.3"
} }
}, },
"node_modules/@fastify/rate-limit": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz",
"integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==",
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.1",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.3.1"
}
},
"node_modules/@fastify/websocket": { "node_modules/@fastify/websocket": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-10.0.1.tgz", "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-10.0.1.tgz",
@@ -801,6 +813,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",

View File

@@ -11,29 +11,29 @@
"lint": "eslint src/ tests/" "lint": "eslint src/ tests/"
}, },
"dependencies": { "dependencies": {
"fastify": "^4.28.0",
"@fastify/cors": "^9.0.0", "@fastify/cors": "^9.0.0",
"@fastify/rate-limit": "^9.1.0",
"@fastify/helmet": "^11.1.0", "@fastify/helmet": "^11.1.0",
"@fastify/rate-limit": "^9.1.0",
"@fastify/websocket": "^10.0.0", "@fastify/websocket": "^10.0.0",
"pg": "^8.12.0", "@slack/bolt": "^3.19.0",
"@slack/web-api": "^7.1.0",
"fastify": "^4.28.0",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"zod": "^3.23.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"pino": "^9.1.0", "pino": "^9.1.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"@slack/web-api": "^7.1.0", "zod": "^3.23.0"
"@slack/bolt": "^3.19.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.0", "@types/jsonwebtoken": "^9.0.6",
"tsx": "^4.15.0",
"vitest": "^1.6.0",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"eslint": "^9.5.0" "eslint": "^9.5.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0",
"vitest": "^1.6.0"
} }
} }

View File

@@ -96,7 +96,7 @@ export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h
async function hashPassword(password: string): Promise<string> { async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(`${salt}:${derived.toString('hex')}`); resolve(`${salt}:${derived.toString('hex')}`);
}); });
@@ -106,7 +106,7 @@ async function hashPassword(password: string): Promise<string> {
async function verifyPassword(password: string, hash: string): Promise<boolean> { async function verifyPassword(password: string, hash: string): Promise<boolean> {
const [salt, key] = hash.split(':'); const [salt, key] = hash.split(':');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived)); resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived));
}); });
@@ -178,9 +178,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
let role: string; let role: string;
if (body.invite_token) { if (body.invite_token) {
const tokenHash = crypto.createHash('sha256').update(body.invite_token).digest('hex');
const invite = await client.query( const invite = await client.query(
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, `SELECT id, tenant_id, email, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1 FOR UPDATE`,
[body.invite_token], [tokenHash],
); );
if (!invite.rows[0]) { if (!invite.rows[0]) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -195,6 +196,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Invite expired' }); return reply.status(400).send({ error: 'Invite expired' });
} }
if (inv.email && inv.email.toLowerCase() !== body.email.toLowerCase()) {
await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Email does not match invite' });
}
tenantId = inv.tenant_id; tenantId = inv.tenant_id;
role = inv.role; role = inv.role;
@@ -278,11 +283,12 @@ export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: str
const body = inviteSchema.parse(req.body); const body = inviteSchema.parse(req.body);
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
const inviteTokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await pool.query( const result = await pool.query(
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING expires_at`, RETURNING expires_at`,
[tenantId, body.email, body.role, token, userId], [tenantId, body.email, body.role, inviteTokenHash, userId],
); );
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });

View File

@@ -11,5 +11,9 @@ const envSchema = z.object({
LOG_LEVEL: z.string().default('info'), LOG_LEVEL: z.string().default('info'),
}); });
export const config = envSchema.parse(process.env); const parsed = envSchema.parse(process.env);
if (process.env.NODE_ENV === 'production' && parsed.JWT_SECRET.includes('change-me')) {
throw new Error('FATAL: JWT_SECRET must be changed in production');
}
export const config = parsed;
export type Config = z.infer<typeof envSchema>; export type Config = z.infer<typeof envSchema>;

View File

@@ -14,7 +14,7 @@ export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`); await client.query('SELECT set_config($1, $2, true)', ['app.tenant_id', tenantId]);
const result = await fn(client); const result = await fn(client);
await client.query('COMMIT'); await client.query('COMMIT');
return result; return result;

View File

@@ -1,6 +1,7 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import helmet from '@fastify/helmet'; import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import pino from 'pino'; import pino from 'pino';
import { config } from './config/index.js'; import { config } from './config/index.js';
import { getPoolForAuth } from './data/db.js'; import { getPoolForAuth } from './data/db.js';
@@ -15,6 +16,7 @@ const app = Fastify({ logger: true });
await app.register(cors, { origin: config.CORS_ORIGIN }); await app.register(cors, { origin: config.CORS_ORIGIN });
await app.register(helmet); await app.register(helmet);
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' });
const pool = getPoolForAuth(); const pool = getPoolForAuth();
decorateAuth(app); decorateAuth(app);

View File

@@ -0,0 +1,70 @@
# Adversarial Security & Architecture Review
**Commit:** eb953cd
**Focus:** Security Hardening Changes
## Overview
The security hardening pass successfully implemented a few surface-level mitigations, but the implementation is fundamentally flawed. In several cases, the "fixes" are incomplete, introduced new critical vulnerabilities, or suffer from severe architectural oversights. The system is still highly vulnerable.
## PASS
- **JWT Algorithms:** Hardcoding `{ algorithms: ['HS256'] }` correctly mitigates algorithm confusion attacks.
- **Plugin Encapsulation:** Registering the `authHook` locally inside `protectedApp.addHook` correctly prevents bypasses compared to global URL prefix matching.
- **CORS:** Locked down correctly to `http://localhost:5173`.
- **Async Webhooks:** Redis `LPUSH`/`BRPOP` architecture and dead-letter queue provide a reasonable foundation for async processing.
## FAIL
### 1. [CRITICAL] SQL Injection in RLS Wrapper (`db.ts`)
**File:** `03-alert-intelligence/src/data/db.ts:13`
**Issue:** `await client.query(\`SET LOCAL app.tenant_id = '${tenantId}'\`);`
The `tenantId` is string-interpolated directly into the query instead of being parameterized. While `tenantId` comes from a signed JWT, if the JWT secret is compromised (see issue #10) or user input ever leaks into this function, it allows trivial SQL injection.
**Fix:** Use `await client.query('SELECT set_config($1, $2, true)', ['app.tenant_id', tenantId]);`
### 2. [CRITICAL] Rate Limiting is a Mirage (`index.ts` & `middleware.ts`)
**File:** `03-alert-intelligence/src/index.ts`
**Issue:** The route definitions proudly declare `{ config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }`, but the `@fastify/rate-limit` plugin is **never actually registered** on the Fastify app. Furthermore, the planned "100 req/min global" limit is entirely missing. Rate limiting is 100% bypassable right now.
### 3. [CRITICAL] Invite Token Hijacking (`middleware.ts`)
**File:** `03-alert-intelligence/src/auth/middleware.ts:168`
**Issue:** During signup via an `invite_token`, the server pulls the invite record but *never verifies that `body.email` matches the invited email*. Anyone who obtains the invite link can register an account using their own email address, completely bypassing intended access controls.
### 4. [HIGH] Fragmented Identity Architecture (Microservice Anti-Pattern)
**File:** `03-alert`, `05-cost`, `04-portal` (all products)
**Issue:** The authentication routes, invite logic, and `users`/`tenants` DB tables have been copy-pasted into *every* microservice. Since each service uses its own separate database (`dd0c_alert`, `dd0c_cost`, etc.), identity is completely siloed. A user who signs up in the Portal doesn't exist in the Alert database. This breaks the unified console experience and means foreign keys will fail if a valid JWT from one service is used against another.
### 5. [HIGH] Webhook HMAC Verification Broken on Valid Payloads
**File:** `03-alert-intelligence/src/api/webhooks.ts`
**Issue:** Reconstructing the raw body via `const rawBody = JSON.stringify(body);` for HMAC validation is disastrous. Fastify parses the JSON, and stringifying it back will reorder keys or alter whitespace, meaning perfectly valid Datadog/PagerDuty webhooks will fail signature verification randomly. You must use a raw body parser plugin.
### 6. [HIGH] Token TOCTOU Race Condition
**File:** `03-alert-intelligence/src/auth/middleware.ts:175`
**Issue:** When checking and updating an invite (`SELECT ... FROM tenant_invites` followed by `UPDATE ... SET accepted_at = NOW()`), there is no row-level locking (e.g., `FOR UPDATE`). An attacker can send concurrent requests to use the same invite token multiple times before the first request marks it accepted.
### 7. [HIGH] Webhook Incident Creation Race Condition
**File:** `03-alert-intelligence/src/workers/webhook-processor.ts:51`
**Issue:** The worker queries `SELECT id FROM incidents WHERE fingerprint = $1`. If no incident exists, it inserts one. If two webhooks for the same fingerprint are popped by parallel workers, both will see no incident and insert duplicate incidents.
## WARN
### 8. [MEDIUM] `getPoolForAuth()` Defeats Pool Encapsulation
**File:** `03-alert-intelligence/src/data/db.ts`
**Issue:** The PR claimed to restrict access to the raw DB pool, but merely added an export named `getPoolForAuth()` which returns the raw `pg.Pool`. This allows any developer to easily bypass the `withTenant` RLS controls by importing the raw pool.
### 9. [MEDIUM] Timing Attack Vector in Grafana Webhooks
**File:** `03-alert-intelligence/src/api/webhooks.ts:80`
**Issue:** `token !== secret.secret` uses standard string equality instead of `crypto.timingSafeEqual`. While low risk for standard APIs, webhook secrets should be validated securely to prevent timing attacks.
### 10. [MEDIUM] Insecure Default Secrets in Infrastructure
**File:** `docker-compose.yml:51`
**Issue:** The compose file falls back to `${JWT_SECRET:-dev-secret-change-me-in-production!!}`. If operations forgets to configure `JWT_SECRET` in a production environment, the app fails open with a globally known secret, leading to total environment compromise.
### 11. [LOW] "Least Privilege" Misnomer
**File:** `docker-init-db.sh:15`
**Issue:** The script claims to create "least-privilege" users, but immediately runs `GRANT ALL PRIVILEGES ON DATABASE` and `GRANT ALL ON SCHEMA public`. The service users have full DDL rights and can DROP or TRUNCATE tables.
### 12. [LOW] Plaintext Invite Tokens in Database
**File:** `shared/003_invites.sql`
**Issue:** `tenant_invites.token` is stored as plaintext. If the database is dumped or compromised, the attacker has a list of active invite links. They should be stored as SHA-256 hashes, similar to API keys.
### 13. [LOW] Weak Password Hashing Defaults
**File:** `03-alert-intelligence/src/auth/middleware.ts:84`
**Issue:** `crypto.scrypt` is called without explicit options, defaulting to Node.js's native `N=16384`. This is considered too low for modern offline cracking resistance. Explicit parameters (e.g., `N=65536`) should be provided.

View File

@@ -15,9 +15,10 @@ create_service_user() {
local pass="${!pass_var:-dd0c-dev}" local pass="${!pass_var:-dd0c-dev}"
echo "Creating user $user for $db" echo "Creating user $user for $db"
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname postgres -c "CREATE USER $user WITH PASSWORD '$pass';" 2>/dev/null || true psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname postgres -c "CREATE USER $user WITH PASSWORD '$pass';" 2>/dev/null || true
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "GRANT ALL PRIVILEGES ON DATABASE $db TO $user;" 2>/dev/null || true psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "GRANT CONNECT ON DATABASE $db TO $user;" 2>/dev/null || true
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "GRANT ALL ON SCHEMA public TO $user;" 2>/dev/null || true psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "GRANT USAGE ON SCHEMA public TO $user;" 2>/dev/null || true
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $user;" 2>/dev/null || true psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $user;" 2>/dev/null || true
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO $user;" 2>/dev/null || true
} }
create_service_user dd0c_drift dd0c_drift DB_DRIFT_PASSWORD create_service_user dd0c_drift dd0c_drift DB_DRIFT_PASSWORD

View File

@@ -86,7 +86,7 @@ export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h
async function hashPassword(password: string): Promise<string> { async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(`${salt}:${derived.toString('hex')}`); resolve(`${salt}:${derived.toString('hex')}`);
}); });
@@ -96,7 +96,7 @@ async function hashPassword(password: string): Promise<string> {
async function verifyPassword(password: string, hash: string): Promise<boolean> { async function verifyPassword(password: string, hash: string): Promise<boolean> {
const [salt, key] = hash.split(':'); const [salt, key] = hash.split(':');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derived) => { crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, (err, derived) => {
if (err) reject(err); if (err) reject(err);
resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived)); resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived));
}); });
@@ -169,9 +169,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
if (body.invite_token) { if (body.invite_token) {
// Invite-based signup // Invite-based signup
const tokenHash = crypto.createHash('sha256').update(body.invite_token).digest('hex');
const invite = await client.query( const invite = await client.query(
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, `SELECT id, tenant_id, email, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1 FOR UPDATE`,
[body.invite_token], [tokenHash],
); );
if (!invite.rows[0]) { if (!invite.rows[0]) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -186,6 +187,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Invite expired' }); return reply.status(400).send({ error: 'Invite expired' });
} }
if (inv.email && inv.email.toLowerCase() !== body.email.toLowerCase()) {
await client.query('ROLLBACK');
return reply.status(400).send({ error: 'Email does not match invite' });
}
tenantId = inv.tenant_id; tenantId = inv.tenant_id;
role = inv.role; role = inv.role;
@@ -271,11 +276,12 @@ export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: str
const body = inviteSchema.parse(req.body); const body = inviteSchema.parse(req.body);
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await pool.query( const result = await pool.query(
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING expires_at`, RETURNING expires_at`,
[tenantId, body.email, body.role, token, userId], [tenantId, body.email, body.role, tokenHash, userId],
); );
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });