feat(backend): AI/agent environment config with SSRF-safe provider URL validation

This commit is contained in:
lakshit verma 2026-08-19 17:56:42 +05:30
parent 3f3fdf8a85
commit e88568139c
No known key found for this signature in database
2 changed files with 124 additions and 32 deletions

View file

@ -15,36 +15,123 @@ for (const p of envPaths) {
const placeholderRegex = /^(secret|changeme|replace-me)$/i;
const envSchema = z.object({
PORT: z.coerce.number().int().min(0).max(65535).default(4000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
MONGODB_URI: z.string().min(1, 'MONGODB_URI is required'),
JWT_SECRET: z
.string()
.min(32, 'JWT_SECRET must be at least 32 characters')
.refine((s) => !placeholderRegex.test(s), { message: 'JWT_SECRET must not be a placeholder value' }),
COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 characters'),
CORS_ORIGIN: z.string().min(1, 'CORS_ORIGIN is required'),
APP_URL: z.string().min(1, 'APP_URL is required'),
RESEND_API_KEY: z.string().optional(),
RESEND_FROM: z.string().optional(),
TRUST_PROXY_HOPS: z.coerce.number().int().min(0).default(0),
DEBUG_ERRORS: z
.enum(['true', 'false'])
.default('false')
.transform((v) => v === 'true'),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent']).default('info'),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60000),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
RATE_LIMIT_LOGIN_MAX: z.coerce.number().int().positive().default(10),
RATE_LIMIT_FORGOT_MAX: z.coerce.number().int().positive().default(3),
SEED_ADMIN_EMAIL: z.string().email().default('admin@example.com'),
SEED_ADMIN_PASSWORD: z.string().min(8).default('Admin1234'),
SEED_USER_EMAIL: z.string().email().default('user@example.com'),
SEED_USER_PASSWORD: z.string().min(8).default('User1234'),
SEED_VIEWER_EMAIL: z.string().email().default('viewer@example.com'),
SEED_VIEWER_PASSWORD: z.string().min(8).default('Viewer1234'),
});
function isPrivateHostname(hostname: string): boolean {
const h = hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (h === 'localhost' || h === '::1' || h === '0.0.0.0') return true;
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
return false;
}
const envSchema = z
.object({
PORT: z.coerce.number().int().min(0).max(65535).default(4000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
MONGODB_URI: z.string().min(1, 'MONGODB_URI is required'),
JWT_SECRET: z
.string()
.min(32, 'JWT_SECRET must be at least 32 characters')
.refine((s) => !placeholderRegex.test(s), {
message: 'JWT_SECRET must not be a placeholder value',
}),
COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 characters'),
CORS_ORIGIN: z.string().min(1, 'CORS_ORIGIN is required'),
APP_URL: z.string().min(1, 'APP_URL is required'),
RESEND_API_KEY: z.string().optional(),
RESEND_FROM: z.string().optional(),
TRUST_PROXY_HOPS: z.coerce.number().int().min(0).default(0),
DEBUG_ERRORS: z
.enum(['true', 'false'])
.default('false')
.transform((v) => v === 'true'),
LOG_LEVEL: z
.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'])
.default('info'),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60000),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
RATE_LIMIT_LOGIN_MAX: z.coerce.number().int().positive().default(10),
RATE_LIMIT_FORGOT_MAX: z.coerce.number().int().positive().default(3),
SEED_ADMIN_EMAIL: z.string().email().default('admin@example.com'),
SEED_ADMIN_PASSWORD: z.string().min(8).default('Admin1234'),
SEED_USER_EMAIL: z.string().email().default('user@example.com'),
SEED_USER_PASSWORD: z.string().min(8).default('User1234'),
SEED_VIEWER_EMAIL: z.string().email().default('viewer@example.com'),
SEED_VIEWER_PASSWORD: z.string().min(8).default('Viewer1234'),
AI_ENABLED: z
.enum(['true', 'false'])
.default('false')
.transform((v) => v === 'true'),
AI_BASE_URL: z.string().optional(),
AI_API_KEY: z.string().optional(),
AI_MODEL: z.string().default('gpt-4o-mini'),
AI_MAX_STEPS_PER_RUN: z.coerce.number().int().positive().default(8),
AI_MAX_OUTPUT_TOKENS: z.coerce.number().int().positive().default(2048),
AI_MAX_CONTEXT_TOKENS: z.coerce.number().int().positive().default(16384),
AI_PRICE_PER_1M_INPUT: z.coerce.number().nonnegative().default(0.15),
AI_PRICE_PER_1M_OUTPUT: z.coerce.number().nonnegative().default(0.6),
AI_DAILY_SPEND_USD: z.coerce.number().nonnegative().default(1),
AGENT_DAILY_SPEND_USD: z.coerce.number().nonnegative().default(1),
AI_GLOBAL_DAILY_SPEND_USD: z.coerce.number().nonnegative().default(5),
AI_APPROVAL_TTL_MS: z.coerce.number().int().positive().default(600000),
AI_SSE_KEEPALIVE_MS: z.coerce.number().int().positive().default(15000),
AI_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(20),
AGENT_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(5000),
AGENT_LEASE_MS: z.coerce.number().int().positive().default(15000),
AGENT_SWEEP_INTERVAL_MS: z.coerce.number().int().positive().default(30000),
AGENT_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3),
AGENT_RECONCILE_AFTER_MS: z.coerce.number().int().positive().default(60000),
AGENT_TRIAGE_MODE: z.enum(['suggest', 'auto-apply']).default('suggest'),
AGENT_WORKING_HOURS: z.string().default('*'),
AGENT_CONCURRENCY: z.coerce.number().int().positive().default(2),
})
.superRefine((data, ctx) => {
if (data.AI_ENABLED) {
if (!data.AI_BASE_URL) {
ctx.addIssue({
code: 'custom',
path: ['AI_BASE_URL'],
message: 'AI_BASE_URL is required when AI_ENABLED=true',
});
}
if (!data.AI_API_KEY) {
ctx.addIssue({
code: 'custom',
path: ['AI_API_KEY'],
message: 'AI_API_KEY is required when AI_ENABLED=true',
});
}
}
if (data.AI_BASE_URL) {
let url: URL;
try {
url = new URL(data.AI_BASE_URL);
} catch {
ctx.addIssue({
code: 'custom',
path: ['AI_BASE_URL'],
message: 'AI_BASE_URL must be a valid absolute https URL',
});
return;
}
if (url.protocol !== 'https:') {
ctx.addIssue({
code: 'custom',
path: ['AI_BASE_URL'],
message: 'AI_BASE_URL must use the https protocol',
});
}
if (isPrivateHostname(url.hostname)) {
ctx.addIssue({
code: 'custom',
path: ['AI_BASE_URL'],
message: 'AI_BASE_URL must not point to a private, loopback, or link-local address',
});
}
}
});
const parsed = envSchema.safeParse(process.env);
@ -62,4 +149,4 @@ export const env = parsed.data;
export const corsOrigins = env.CORS_ORIGIN.split(',')
.map((o) => o.trim())
.filter(Boolean);
.filter(Boolean);

View file

@ -9,12 +9,17 @@ export const logger = pino({
paths: [
'req.headers.cookie',
'req.headers.authorization',
'req.headers["x-api-key"]',
'password',
'passwordHash',
'token',
'tokenHash',
'currentPassword',
'newPassword',
'apiKey',
'aiApiKey',
'AI_API_KEY',
'x-api-key',
],
censor: '[redacted]',
},
@ -31,4 +36,4 @@ export const httpLogger = pinoHttp({
paths: ['req.headers.cookie', 'req.headers.authorization'],
censor: '[redacted]',
},
});
});