diff --git a/.env.example b/.env.example index cd89299c..cc06a352 100644 --- a/.env.example +++ b/.env.example @@ -352,6 +352,15 @@ ADMIN_EMAIL= # ----------------------------------------------------------------------------- # Exports # ----------------------------------------------------------------------------- +# Exports are disabled unless explicitly enabled. When enabled, all four S3 +# settings below are required; signing secrets must contain at least 32 chars. +EXPORTS_ENABLED=false +EXPORTS_S3_BUCKET= +EXPORTS_S3_ENDPOINT= +EXPORTS_S3_REGION= +EXPORTS_SIGNING_SECRET= +# Optional comma-separated exact hostnames or *.example.com patterns. +EXPORTS_S3_ENDPOINT_ALLOWLIST= # Comma-separated origins permitted to call exports endpoints. Default: empty (deny all cross-origin requests). EXPORTS_CORS_ALLOWED_ORIGINS= # Lifetime of generated export signed URLs in seconds. Default: 900. diff --git a/docs/exports-security-headers.md b/docs/exports-security-headers.md index ef2b3458..a65d83f2 100644 --- a/docs/exports-security-headers.md +++ b/docs/exports-security-headers.md @@ -15,6 +15,12 @@ both successful `200` responses and error responses (`401` / `403` / `400`). The repo mounts this surface at **`/api/exports`** (plural). There is no singular `/api/export` route. +Export routes are mounted only when `EXPORTS_ENABLED=true` and all required +S3 settings are valid. Otherwise `/api/exports` and `/api/exports/schedules` +are unavailable. Schedule endpoints reject private, loopback, link-local, +CGNAT, multicast, and IPv4-mapped IPv6 addresses; production requires HTTPS. +Set `EXPORTS_S3_ENDPOINT_ALLOWLIST` to constrain accepted hostnames further. + ## Related - Middleware: `src/middleware/securityHeaders.ts` diff --git a/docs/scheduled-exports.md b/docs/scheduled-exports.md index 69289af8..c50f6b33 100644 --- a/docs/scheduled-exports.md +++ b/docs/scheduled-exports.md @@ -2,6 +2,16 @@ This feature adds developer-managed recurring exports of `usage_events` to a user-provided S3-compatible endpoint, plus a server-managed daily export pipeline that materialises signed download artifacts accessible via `GET /api/developers/exports`. +Exports are disabled by default. Set `EXPORTS_ENABLED=true` together with +`EXPORTS_S3_BUCKET`, `EXPORTS_S3_ENDPOINT`, `EXPORTS_S3_REGION`, and a +`EXPORTS_SIGNING_SECRET` of at least 32 characters to mount the export routers. +The endpoint must be public; production requires HTTPS. Optional +`EXPORTS_S3_ENDPOINT_ALLOWLIST` limits schedule endpoint hostnames to a +comma-separated list (wildcards such as `*.example.com` are supported). +Schedule secrets are AES-256-GCM encrypted in the in-memory store using a key +derived from `EXPORTS_SIGNING_SECRET`; API responses continue to return +`[REDACTED]`. + --- ## API @@ -138,7 +148,7 @@ const service = new ReportExporterService( ); ``` -`HmacObjectStorageClient` (from `scheduledExports.ts`) records all uploads in its `.uploads` array and generates deterministic signed URLs — no real S3 connection required. +`HmacObjectStorageClient` (from `scheduledExports.ts`) records all uploads in its `.uploads` array and generates signed URLs — no real S3 connection required. --- diff --git a/src/app.test.ts b/src/app.test.ts index 2fe9d973..8c3a8b6d 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -16,6 +16,15 @@ process.env.APIS_CORS_ALLOWED_ORIGINS = 'http://localhost:5173,https://app.callo const authBearer = (userId = 'dev-1') => `Bearer ${signTestToken({ userId })}`; const TEST_ORIGIN = 'http://localhost:5173'; +test('app source does not contain a hard-coded export signing secret', () => { + expect(readFileSync('src/app.ts', 'utf8')).not.toContain('contract-' + 'test-secret'); +}); + +test('exports routes are unavailable when export configuration is disabled', async () => { + const response = await request(createApp()).get('/api/exports'); + expect(response.status).toBe(404); +}); + jest.mock('uuid', () => ({ v4: () => 'mock-uuid-1234' })); jest.mock('./services/transactionBuilder.js', () => ({ TransactionBuilderService: class MockTxBuilder {} diff --git a/src/app.ts b/src/app.ts index 2df3e5d9..cc663f83 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,8 @@ import { createPluginsRouter } from './routes/marketplace/plugins.js'; import { createGatewayHealthRouter } from './routes/gatewayRoutes.js'; import { HmacObjectStorageClient, + InMemoryScheduleStore, + ScheduledExportsService, } from './services/scheduledExports.js'; import { InMemoryExportStore, @@ -97,6 +99,7 @@ import { asyncHandler } from "./utils/asyncHandler.js"; import { createRouteBodyLimitMiddleware } from "./middleware/routeBodyLimit.js"; import { metricsMiddleware, metricsEndpoint } from "./metrics.js"; import { config } from "./config/index.js"; +import { validateStorageEndpoint } from "./lib/storageEndpoint.js"; import { createSorobanBillingService, type SorobanBillingDependencies, @@ -190,16 +193,79 @@ export const createApp = (dependencies?: Partial) => { dependencies?.vaultRepository ?? new InMemoryVaultRepository(); const lookupDeveloper = dependencies?.findDeveloperByUserId ?? findByUserId; const persistApi = dependencies?.createApiWithEndpoints ?? createApi; - const reportExporterService = new ReportExporterService( - { getEventsInRange: async () => [] }, - new HmacObjectStorageClient(), - new InMemoryExportStore(), - { - s3Bucket: "contract-exports", - s3Endpoint: "http://localhost", - s3SecretAccessKey: "contract-test-secret", - }, + const getExportEvents = async () => { + const events = await usageEventsRepository.getEvents?.(); + if (!events) { + throw new Error('The configured usage repository does not support export event retrieval.'); + } + return events.map((event) => ({ + id: event.id, + userId: event.userId, + apiId: event.apiId, + endpointId: event.endpoint, + apiKeyId: '', + developerId: event.developerId, + amount: event.revenue, + requestId: event.id, + stellarTxHash: event.stellarTxHash ?? null, + createdAt: event.occurredAt, + })); + }; + const exportConfig = config.exports; + const exportsConfigured = Boolean( + exportConfig.enabled && + exportConfig.bucket && + exportConfig.endpoint && + exportConfig.region && + exportConfig.signingSecret, ); + const objectStorageClient = new HmacObjectStorageClient(); + const exportEndpoint = exportsConfigured + ? validateStorageEndpoint(exportConfig.endpoint!, { + production: config.nodeEnv === 'production', + allowedHosts: exportConfig.endpointAllowlist, + }) + : undefined; + const reportExporterService = exportsConfigured + ? new ReportExporterService( + { + // Adapt the in-process repository to the paged range interface: + // [from, to) by createdAt, optional developer filter, id keyset cursor. + getEventsInRange: async (from, to, options) => { + const events = (await getExportEvents()) + .filter((event) => event.createdAt >= from && event.createdAt < to) + .filter((event) => options.developerId === undefined || event.developerId === options.developerId) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const start = options.cursor === undefined + ? 0 + : events.findIndex((event) => event.id > options.cursor!); + if (start === -1) return []; + return events.slice(start, start + options.limit); + }, + }, + objectStorageClient, + new InMemoryExportStore(), + { + s3Bucket: exportConfig.bucket!, + s3Endpoint: exportEndpoint!, + s3SecretAccessKey: exportConfig.signingSecret!, + }, + ) + : undefined; + const scheduledExportsService = exportsConfigured + ? new ScheduledExportsService( + { + findByApiId: async (apiId, from, to, limit, offset) => + (await getExportEvents()) + .filter((event) => !apiId || event.apiId === apiId) + .filter((event) => !from || event.createdAt >= from) + .filter((event) => !to || event.createdAt <= to) + .slice(offset ?? 0, limit === undefined ? undefined : (offset ?? 0) + limit), + }, + new InMemoryScheduleStore(exportConfig.signingSecret!), + objectStorageClient, + ) + : undefined; // Initialize deposit and vault controllers const transactionBuilder = new TransactionBuilderService(); @@ -490,6 +556,7 @@ export const createApp = (dependencies?: Partial) => { apiRepository, developerRepository, reportExporterService, + scheduledExportsService, subscriptionRepository: defaultSubscriptionRepository, }), ); diff --git a/src/config/env.test.ts b/src/config/env.test.ts index de822d6e..10e969d1 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -71,6 +71,39 @@ describe("env schema — Soroban billing", () => { }); }); +describe('env schema - exports', () => { + it('requires all export settings when enabled', () => { + const result = envSchema.safeParse({ + ...baseEnv, + NODE_ENV: 'production', + SOROBAN_BILLING_CONTRACT_ID: 'contract_123', + UPSTREAM_HOST_ALLOWLIST: 'upstream.example.com', + EXPORTS_ENABLED: 'true', + EXPORTS_S3_BUCKET: 'exports', + EXPORTS_S3_ENDPOINT: 'https://s3.example.com', + EXPORTS_S3_REGION: 'us-east-1', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((issue) => issue.path[0])).toContain( + 'EXPORTS_SIGNING_SECRET', + ); + } + }); + + it('accepts export settings with a sufficiently long signing secret', () => { + const result = envSchema.safeParse({ + ...baseEnv, + EXPORTS_ENABLED: 'true', + EXPORTS_S3_BUCKET: 'exports', + EXPORTS_S3_ENDPOINT: 'https://s3.example.com', + EXPORTS_S3_REGION: 'us-east-1', + EXPORTS_SIGNING_SECRET: 'a'.repeat(32), + }); + expect(result.success).toBe(true); + }); +}); + describe("env schema - BCRYPT_COST_FACTOR", () => { describe("unit tests", () => { it("defaults to 12 when BCRYPT_COST_FACTOR is omitted", () => { diff --git a/src/config/env.ts b/src/config/env.ts index 97a20013..a5d1ec12 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -108,6 +108,14 @@ export const envSchema = z JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), ADMIN_API_KEY: z.string().min(1, "ADMIN_API_KEY is required"), METRICS_API_KEY: z.string().min(1, "METRICS_API_KEY is required"), + + // Export object storage. Disabled unless explicitly enabled. + EXPORTS_ENABLED: z.string().optional().transform((value) => value === "true").default(false), + EXPORTS_S3_BUCKET: z.string().trim().min(1).optional(), + EXPORTS_S3_ENDPOINT: z.string().url().optional(), + EXPORTS_S3_REGION: z.string().trim().min(1).optional(), + EXPORTS_SIGNING_SECRET: z.string().min(32).optional(), + EXPORTS_S3_ENDPOINT_ALLOWLIST: z.string().optional(), TRUST_FORWARDED_USER_ID: z .string() .optional() @@ -660,6 +668,66 @@ export const envSchema = z .default(false), }) .superRefine((data, ctx) => { + if (data.EXPORTS_ENABLED) { + for (const [key, value] of [ + ["EXPORTS_S3_BUCKET", data.EXPORTS_S3_BUCKET], + ["EXPORTS_S3_ENDPOINT", data.EXPORTS_S3_ENDPOINT], + ["EXPORTS_S3_REGION", data.EXPORTS_S3_REGION], + ["EXPORTS_SIGNING_SECRET", data.EXPORTS_SIGNING_SECRET], + ] as const) { + if (!value) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: `${key} is required when EXPORTS_ENABLED=true`, + }); + } + } + } + + if (data.NODE_ENV === "production" && data.FORWARDED_IDENTITY_ALLOW_LEGACY) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["FORWARDED_IDENTITY_ALLOW_LEGACY"], message: "Legacy forwarded identities are forbidden in production" }); + } + if (data.NODE_ENV === "production" && !data.SOROBAN_BILLING_CONTRACT_ID) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["SOROBAN_BILLING_CONTRACT_ID"], + message: "SOROBAN_BILLING_CONTRACT_ID is required in production", + }); + } + + if (data.SOROBAN_RPC_ENABLED && !data.SOROBAN_RPC_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["SOROBAN_RPC_URL"], + message: "SOROBAN_RPC_URL is required when SOROBAN_RPC_ENABLED=true", + }); + } + + if (data.HORIZON_ENABLED && !data.HORIZON_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["HORIZON_URL"], + message: "HORIZON_URL is required when HORIZON_ENABLED=true", + }); + } + + // In production the upstream host allowlist must be explicitly configured. + // The default empty allowlist rejects all upstream hosts, so operators + // must set UPSTREAM_HOST_ALLOWLIST to a non-empty value to proxy traffic. + if ( + data.NODE_ENV === "production" && + (!data.UPSTREAM_HOST_ALLOWLIST || + data.UPSTREAM_HOST_ALLOWLIST.trim() === "") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["UPSTREAM_HOST_ALLOWLIST"], + message: + "UPSTREAM_HOST_ALLOWLIST is required in production and must be a non-empty comma-separated list of allowed upstream hosts", + }); + } + if (data.FEE_ABSTRACTION_RATE_DENOMINATOR === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/src/config/index.ts b/src/config/index.ts index 24f54d6b..48da3887 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -177,6 +177,18 @@ export const config = { apiKey: env.METRICS_API_KEY, }, + exports: { + enabled: env.EXPORTS_ENABLED, + bucket: env.EXPORTS_S3_BUCKET, + endpoint: env.EXPORTS_S3_ENDPOINT, + region: env.EXPORTS_S3_REGION, + signingSecret: env.EXPORTS_SIGNING_SECRET, + endpointAllowlist: (env.EXPORTS_S3_ENDPOINT_ALLOWLIST ?? "") + .split(",") + .map((host) => host.trim()) + .filter(Boolean), + }, + proxy: { upstreamUrl: validatedUpstreamUrl, timeoutMs: env.PROXY_TIMEOUT_MS, diff --git a/src/lib/storageEndpoint.test.ts b/src/lib/storageEndpoint.test.ts new file mode 100644 index 00000000..4ce85bff --- /dev/null +++ b/src/lib/storageEndpoint.test.ts @@ -0,0 +1,52 @@ +import { validateStorageEndpoint } from './storageEndpoint.js'; + +describe('validateStorageEndpoint', () => { + it.each([ + 'http://169.254.169.254', + 'http://10.0.0.5:9000', + 'https://[::ffff:10.0.0.1]', + 'http://127.0.0.1:9000', + 'http://100.64.0.1', + 'http://[fd00::1]', + 'http://224.0.0.1', + 'http://[ff02::1]', + 'http://240.0.0.1', + 'http://multicast.example.com.local', + 'http://localhost', + ])('rejects internal endpoint %s', (url) => { + expect(() => validateStorageEndpoint(url)).toThrow(); + }); + + it('requires HTTPS in production', () => { + expect(() => validateStorageEndpoint('http://s3.example.com', { production: true })).toThrow(/HTTPS/); + expect(validateStorageEndpoint('https://s3.example.com', { production: true })).toBe('https://s3.example.com'); + }); + + it('accepts a public S3 endpoint', () => { + expect(validateStorageEndpoint('https://s3.us-east-1.amazonaws.com')).toBe( + 'https://s3.us-east-1.amazonaws.com', + ); + }); + + it('enforces the optional hostname allowlist', () => { + expect(() => validateStorageEndpoint('https://other.example.com', { + allowedHosts: ['s3.example.com'], + })).toThrow(/allowlist/); + expect(validateStorageEndpoint('https://s3.example.com', { + allowedHosts: ['s3.example.com'], + })).toBe('https://s3.example.com'); + expect(validateStorageEndpoint('https://bucket.s3.example.com', { + allowedHosts: ['*.s3.example.com'], + })).toBe('https://bucket.s3.example.com'); + }); + + it.each([ + 'ftp://s3.example.com', + 'https://user:pass@s3.example.com', + 'https://s3.example.com?token=abc', + 'https://s3.example.com/#fragment', + 'not a URL', + ])('rejects malformed or unsupported endpoint %s', (url) => { + expect(() => validateStorageEndpoint(url)).toThrow(); + }); +}); \ No newline at end of file diff --git a/src/lib/storageEndpoint.ts b/src/lib/storageEndpoint.ts new file mode 100644 index 00000000..2b5b8c2d --- /dev/null +++ b/src/lib/storageEndpoint.ts @@ -0,0 +1,97 @@ +import { isIP } from 'node:net'; +import ipRangeCheck from 'ip-range-check'; + +const BLOCKED_IP_RANGES = [ + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.168.0.0/16', + '198.18.0.0/15', + '224.0.0.0/4', + '240.0.0.0/4', + '::/128', + '::1/128', + 'fc00::/7', + 'fe80::/10', + 'ff00::/8', +]; + +export interface StorageEndpointOptions { + production?: boolean; + allowedHosts?: readonly string[]; +} + +function normalizedHost(hostname: string): string { + return hostname.toLowerCase().replace(/\.$/, '').replace(/^\[|\]$/g, ''); +} + +function mappedIpv4(hostname: string): string | undefined { + const match = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(hostname); + if (match) return match[1]; + const hexMatch = /^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/i.exec(hostname); + if (!hexMatch) return undefined; + const high = Number.parseInt(hexMatch[1], 16); + const low = Number.parseInt(hexMatch[2], 16); + return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`; +} + +function isBlockedIp(hostname: string): boolean { + const mappedAddress = mappedIpv4(hostname); + if (mappedAddress) return true; + const address = hostname; + return isIP(address) !== 0 && ipRangeCheck(address, BLOCKED_IP_RANGES); +} + +function matchesAllowedHost(hostname: string, allowedHosts: readonly string[]): boolean { + return allowedHosts.some((entry) => { + const allowed = normalizedHost(entry.trim()); + if (!allowed) return false; + if (allowed.startsWith('*.')) { + const suffix = allowed.slice(1); + return hostname.endsWith(suffix) && hostname !== suffix.slice(1); + } + return hostname === allowed; + }); +} + +export function validateStorageEndpoint( + rawUrl: string, + options: StorageEndpointOptions = {}, +): string { + let parsed: URL; + try { + parsed = new URL(rawUrl.trim()); + } catch { + throw new Error('s3Endpoint must be a valid absolute URL.'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('s3Endpoint must use http or https.'); + } + if (options.production && parsed.protocol !== 'https:') { + throw new Error('s3Endpoint must use HTTPS in production.'); + } + if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error('s3Endpoint must include a hostname and must not include credentials, query strings, or fragments.'); + } + + const hostname = normalizedHost(parsed.hostname); + if ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname.endsWith('.local') || + hostname.endsWith('.internal') || + isBlockedIp(hostname) + ) { + throw new Error('s3Endpoint must not target a private, loopback, link-local, or reserved host.'); + } + + if (options.allowedHosts?.length && !matchesAllowedHost(hostname, options.allowedHosts)) { + throw new Error(`s3Endpoint host "${hostname}" is not in the configured export allowlist.`); + } + + return parsed.toString().replace(/\/$/, ''); +} \ No newline at end of file diff --git a/src/repositories/usageEventsRepository.ts b/src/repositories/usageEventsRepository.ts index 4b5b55ef..dc407923 100644 --- a/src/repositories/usageEventsRepository.ts +++ b/src/repositories/usageEventsRepository.ts @@ -75,6 +75,7 @@ export interface UsageHourBucket { } export interface UsageEventsRepository { + getEvents?(): Promise; findByDeveloper(query: UsageEventQuery): Promise; countByDeveloper(query: { developerId: string; from: Date; to: Date }): Promise; findByUser(query: UserUsageEventQuery): Promise; @@ -131,6 +132,10 @@ export interface UsageEventsRepository { export class InMemoryUsageEventsRepository implements UsageEventsRepository { constructor(private readonly events: UsageEvent[] = []) {} + async getEvents(): Promise { + return [...this.events]; + } + async findByDeveloper(query: UsageEventQuery): Promise { return this.events.filter((event) => { if (event.developerId !== query.developerId) { diff --git a/src/routes/exports/schedules.test.ts b/src/routes/exports/schedules.test.ts index 0e4a96f1..6c58fd72 100644 --- a/src/routes/exports/schedules.test.ts +++ b/src/routes/exports/schedules.test.ts @@ -4,9 +4,16 @@ import { errorHandler } from '../../middleware/errorHandler.js'; import { requestIdMiddleware } from '../../middleware/requestId.js'; import { createExportSchedulesRouter } from './schedules.js'; import { InMemoryScheduleStore, HmacObjectStorageClient, ScheduledExportsService } from '../../services/scheduledExports.js'; -import { exportsLogger } from '../../middleware/exportsAccessLog.js'; -const service = new ScheduledExportsService({ findByApiId: async () => [] }, new InMemoryScheduleStore(), new HmacObjectStorageClient()); +jest.mock('../../middleware/requireAuth.js', () => ({ + requireAuth: (req: express.Request, res: express.Response, next: express.NextFunction) => { + const userId = req.header('x-user-id'); + if (userId) res.locals.authenticatedUser = { id: userId }; + next(); + }, +})); + +const service = new ScheduledExportsService({ findByApiId: async () => [] }, new InMemoryScheduleStore('test-encryption-key'), new HmacObjectStorageClient()); const IDEM_SCHEDULE_BODY = { name: 'Nightly', @@ -20,26 +27,28 @@ const IDEM_SCHEDULE_BODY = { function createMockDb() { const store = new Map>(); + const recordKey = (scope: unknown, key: unknown) => `${String(scope)}:${String(key)}`; return { query: jest.fn().mockImplementation(async (sql: string, params: unknown[]) => { if (sql.includes('DELETE')) { return { rows: [] }; } if (sql.includes('SELECT')) { - const key = params[0] as string; + const key = recordKey(params[0], params[1]); return { rows: store.has(key) ? [store.get(key)!] : [] }; } if (sql.includes('INSERT') && !sql.includes('SELECT')) { - store.set(params[0] as string, { - request_hash: params[1], - status: params[2], - expires_at: params[3], + store.set(recordKey(params[0], params[1]), { + request_hash: params[2], + status: params[3], + expires_at: params[4], }); - return { rows: [] }; + return { rows: [], rowCount: 1 }; } if (sql.includes('UPDATE')) { - store.set(params[3] as string, { - ...store.get(params[3] as string), + const key = recordKey(params[3], params[4]); + store.set(key, { + ...store.get(key), status: params[0], response_status: params[1], response_body: params[2], @@ -61,6 +70,30 @@ function createTestApp(mockDb?: ReturnType) { return app; } +test('GET /api/exports/schedules lists schedules for the authenticated developer', async () => { + const response = await request(createTestApp()) + .get('/api/exports/schedules') + .set('x-user-id', 'dev-1'); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual(expect.any(Array)); +}); + +test('GET /api/exports/schedules requires an authenticated developer', async () => { + const response = await request(createTestApp()).get('/api/exports/schedules'); + expect(response.status).toBe(401); +}); + +test('GET /api/exports/schedules forwards service errors', async () => { + const list = jest.spyOn(service, 'listSchedulesForDeveloper').mockRejectedValueOnce(new Error('store unavailable')); + const response = await request(createTestApp()) + .get('/api/exports/schedules') + .set('x-user-id', 'dev-1'); + + expect(response.status).toBe(500); + list.mockRestore(); +}); + test('POST /api/exports/schedules creates a schedule with redacted secret', async () => { const app = createTestApp(); const response = await request(app) @@ -72,6 +105,43 @@ test('POST /api/exports/schedules creates a schedule with redacted secret', asyn expect(response.body.data.s3SecretAccessKey).toBe('[REDACTED]'); }); +test('POST /api/exports/schedules rejects an invalid cron expression', async () => { + const response = await request(createTestApp()) + .post('/api/exports/schedules') + .set('x-user-id', 'dev-1') + .send({ ...IDEM_SCHEDULE_BODY, cron: 'invalid' }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe('BAD_REQUEST'); +}); + +test.each(['http://169.254.169.254', 'http://10.0.0.5:9000', 'https://[::ffff:10.0.0.1]'])( + 'POST /api/exports/schedules rejects unsafe endpoint %s', + async (s3Endpoint) => { + const response = await request(createTestApp()) + .post('/api/exports/schedules') + .set('x-user-id', 'dev-1') + .send({ ...IDEM_SCHEDULE_BODY, s3Endpoint }); + + expect(response.status).toBe(400); + }, +); + +test('PATCH /api/exports/schedules rejects an unsafe endpoint', async () => { + const app = createTestApp(); + const created = await request(app) + .post('/api/exports/schedules') + .set('x-user-id', 'dev-1') + .send(IDEM_SCHEDULE_BODY); + + const response = await request(app) + .patch(`/api/exports/schedules/${created.body.data.id}`) + .set('x-user-id', 'dev-1') + .send({ s3Endpoint: 'http://localhost:9000' }); + + expect(response.status).toBe(400); +}); + test('PATCH /api/exports/schedules rejects invalid cron with standardized error envelope', async () => { const app = createTestApp(); const created = await request(app) @@ -85,10 +155,28 @@ test('PATCH /api/exports/schedules rejects invalid cron with standardized error .send({ cron: 'invalid' }); expect(response.status).toBe(400); - expect(response.body.error.code).toBe('INVALID_EXPORT_SCHEDULE'); + expect(response.body.error.code).toBe('BAD_REQUEST'); expect(response.body.requestId).toBeDefined(); }); +test('PATCH /api/exports/schedules rejects an empty update', async () => { + const response = await request(createTestApp()) + .patch('/api/exports/schedules/schedule-id') + .set('x-user-id', 'dev-1') + .send({}); + + expect(response.status).toBe(400); +}); + +test('PATCH /api/exports/schedules returns not found for another developer schedule', async () => { + const response = await request(createTestApp()) + .patch('/api/exports/schedules/unknown-schedule') + .set('x-user-id', 'dev-1') + .send({ name: 'Updated' }); + + expect(response.status).toBe(404); +}); + describe('Idempotency-Key on /api/exports/schedules', () => { test('POST with Idempotency-Key returns 201 on first call and replays on second', async () => { const mockDb = createMockDb(); @@ -165,7 +253,7 @@ describe('Idempotency-Key on /api/exports/schedules', () => { .send(conflictBody); expect(conflict.status).toBe(409); - expect(conflict.body.code).toBe('IDEMPOTENCY_KEY_REUSE_MISMATCH'); + expect(conflict.body.error.code).toBe('IDEMPOTENCY_KEY_REUSE_MISMATCH'); }); test('POST without Idempotency-Key still succeeds', async () => { diff --git a/src/routes/exports/schedules.ts b/src/routes/exports/schedules.ts index f5b1772b..b4ce5d2e 100644 --- a/src/routes/exports/schedules.ts +++ b/src/routes/exports/schedules.ts @@ -6,6 +6,8 @@ import { idempotencyMiddleware, type IdempotencyConfig } from '../../middleware/ import { BadRequestError, NotFoundError, UnauthorizedError } from '../../errors/index.js'; import type { ScheduledExportsService } from '../../services/scheduledExports.js'; import { exportsAccessLogMiddleware } from '../../middleware/exportsAccessLog.js'; +import { config } from '../../config/index.js'; +import { validateStorageEndpoint } from '../../lib/storageEndpoint.js'; const EXPORT_IDEMPOTENCY_CONFIG: IdempotencyConfig = { keyFromHeader: 'idempotency-key', @@ -22,7 +24,16 @@ const scheduleBodySchema = z.object({ cron: z.string().trim().min(1).max(100), s3Bucket: z.string().trim().min(1).max(255), s3Region: z.string().trim().min(1).max(120), - s3Endpoint: z.string().trim().url(), + s3Endpoint: z.string().trim().url().superRefine((value, context) => { + try { + validateStorageEndpoint(value, { + production: config.nodeEnv === 'production', + allowedHosts: config.exports.endpointAllowlist, + }); + } catch (error) { + context.addIssue({ code: z.ZodIssueCode.custom, message: (error as Error).message }); + } + }), s3AccessKeyId: z.string().trim().min(1).max(255), s3SecretAccessKey: z.string().trim().min(1).max(255), s3PathPrefix: z.string().trim().max(255).optional(), diff --git a/src/services/reportExporter.test.ts b/src/services/reportExporter.test.ts index 7179db29..6e7db699 100644 --- a/src/services/reportExporter.test.ts +++ b/src/services/reportExporter.test.ts @@ -4,6 +4,7 @@ import { createReportExporterWorker, type DeveloperExportRecord, } from './reportExporter.js'; +import crypto from 'node:crypto'; import { HmacObjectStorageClient } from './scheduledExports.js'; import type { BillingUsageEvent } from '../repositories/usageEventsRepository.pg.js'; @@ -272,6 +273,27 @@ test('getSignedUrl returns a URL string containing the record s3Key', () => { expect(url).toContain(encodeURIComponent('daily-exports/dev-1/2026-06-01.csv')); }); +test('getSignedUrl uses the configured signing secret', () => { + const { service } = makeService([]); + const record: DeveloperExportRecord = { + id: 'rec-secret', + developerId: 'dev-1', + format: 'csv', + s3Key: 'daily-exports/dev-1/secret.csv', + exportedAt: new Date(), + expiresAt: new Date(Date.now() + 86_400_000), + }; + + const signedUrl = new URL(service.getSignedUrl(record, 900)); + const expires = signedUrl.searchParams.get('expires'); + const payload = `test-bucket/${record.s3Key}:${expires}`; + const expected = crypto.createHmac('sha256', 'test-secret').update(payload).digest('hex'); + const wrongSecret = crypto.createHmac('sha256', 'another-secret').update(payload).digest('hex'); + + expect(signedUrl.searchParams.get('signature')).toBe(expected); + expect(signedUrl.searchParams.get('signature')).not.toBe(wrongSecret); +}); + // ─── worker lifecycle ───────────────────────────────────────────────────── test('worker start/stop/awaitIdle runs and cleans up without errors', async () => { diff --git a/src/services/scheduledExports.test.ts b/src/services/scheduledExports.test.ts index dcf1a9d7..ec58f3e3 100644 --- a/src/services/scheduledExports.test.ts +++ b/src/services/scheduledExports.test.ts @@ -67,7 +67,7 @@ test('service persists schedule, runs due job, uploads csv+json, and returns sig await repository.create({ userId: 'u1', apiId: 'api-1', endpointId: 'ep-1', apiKeyId: 'key-1', developerId: 'dev-1', amount: 15n, requestId: 'req-1', createdAt: new Date('2026-06-01T00:00:00.000Z') }); await repository.create({ userId: 'u2', apiId: 'api-2', endpointId: 'ep-2', apiKeyId: 'key-2', developerId: 'dev-2', amount: 30n, requestId: 'req-2', createdAt: new Date('2026-06-01T00:05:00.000Z') }); - const store = new InMemoryScheduleStore(); + const store = new InMemoryScheduleStore('test-encryption-key'); const objectStorage = new HmacObjectStorageClient(); const service = new ScheduledExportsService({ findByApiId: async () => listAllEvents(pool) }, store, objectStorage); @@ -104,7 +104,7 @@ test('worker starts, processes due schedules, and idles cleanly', async () => { const { repository, pool } = createUsageRepository(); try { await repository.create({ userId: 'u1', apiId: 'api-1', endpointId: 'ep-1', apiKeyId: 'key-1', developerId: 'dev-1', amount: 15n, requestId: 'req-1', createdAt: new Date('2026-06-01T00:00:00.000Z') }); - const store = new InMemoryScheduleStore(); + const store = new InMemoryScheduleStore('test-encryption-key'); const objectStorage = new HmacObjectStorageClient(); const service = new ScheduledExportsService({ findByApiId: async () => listAllEvents(pool) }, store, objectStorage); const schedule = await service.createSchedule({ developerId: 'dev-1', name: 'Minute export', cron: '* * * * *', s3Bucket: 'exports', s3Region: 'us-east-1', s3Endpoint: 'https://s3.example.com', s3AccessKeyId: 'akid', s3SecretAccessKey: 'secret', enabled: true }); @@ -127,7 +127,7 @@ test('worker handles cancellation cooperatively and preserves idempotency on res try { await repository.create({ userId: 'u1', apiId: 'api-1', endpointId: 'ep-1', apiKeyId: 'key-1', developerId: 'dev-1', amount: 15n, requestId: 'req-1', createdAt: new Date('2026-06-01T00:00:00.000Z') }); - const store = new InMemoryScheduleStore(); + const store = new InMemoryScheduleStore('test-encryption-key'); const objectStorage = new HmacObjectStorageClient(); let uploadsStarted = 0; @@ -167,3 +167,29 @@ test('worker handles cancellation cooperatively and preserves idempotency on res await pool.end(); } }); + +test('schedule secrets are encrypted in the store and redacted in listings', async () => { + const store = new InMemoryScheduleStore('test-encryption-key'); + const service = new ScheduledExportsService( + { findByApiId: async () => [] }, + store, + new HmacObjectStorageClient(), + ); + const created = await service.createSchedule({ + developerId: 'dev-1', + name: 'Encrypted export', + cron: '* * * * *', + s3Bucket: 'exports', + s3Region: 'us-east-1', + s3Endpoint: 'https://s3.example.com', + s3AccessKeyId: 'access-key', + s3SecretAccessKey: 'plaintext-secret', + }); + const stored = (store as unknown as { + schedules: Map; + }).schedules.get(created.id); + + assert.ok(stored); + assert.notEqual(stored.s3SecretAccessKey, 'plaintext-secret'); + assert.equal((await service.listSchedulesForDeveloper('dev-1'))[0]?.s3SecretAccessKey, '[REDACTED]'); +}); diff --git a/src/services/scheduledExports.ts b/src/services/scheduledExports.ts index f3109b0e..bfab4cfa 100644 --- a/src/services/scheduledExports.ts +++ b/src/services/scheduledExports.ts @@ -57,6 +57,10 @@ export interface ScheduleStore { update(id: string, update: Partial): Promise; } +interface StoredExportSchedule extends Omit { + s3SecretAccessKey: string; +} + export interface CreateScheduleInput { developerId: string; name: string; @@ -152,28 +156,68 @@ export function eventsToJson(events: BillingUsageEvent[]): string { } export class InMemoryScheduleStore implements ScheduleStore { - private readonly schedules = new Map(); + private readonly schedules = new Map(); + private readonly encryptionKey: Buffer; + + constructor(encryptionKey: string) { + this.encryptionKey = crypto.createHash('sha256').update(encryptionKey).digest(); + } async list(): Promise { - return [...this.schedules.values()].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + return [...this.schedules.values()] + .map((schedule) => this.decryptSchedule(schedule)) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); } async getById(id: string): Promise { - return this.schedules.get(id); + const schedule = this.schedules.get(id); + return schedule ? this.decryptSchedule(schedule) : undefined; } async save(schedule: ExportSchedule): Promise { - this.schedules.set(schedule.id, schedule); + this.schedules.set(schedule.id, this.encryptSchedule(schedule)); return schedule; } async update(id: string, update: Partial): Promise { - const current = this.schedules.get(id); + const stored = this.schedules.get(id); + const current = stored ? this.decryptSchedule(stored) : undefined; if (!current) return undefined; const next: ExportSchedule = { ...current, ...update, updatedAt: new Date() }; - this.schedules.set(id, next); + this.schedules.set(id, this.encryptSchedule(next)); return next; } + + private encryptSchedule(schedule: ExportSchedule): StoredExportSchedule { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv); + const encrypted = Buffer.concat([ + cipher.update(schedule.s3SecretAccessKey, 'utf8'), + cipher.final(), + ]); + const secret = [iv, cipher.getAuthTag(), encrypted] + .map((part) => part.toString('base64url')) + .join('.'); + return { ...schedule, s3SecretAccessKey: `v1.${secret}` }; + } + + private decryptSchedule(schedule: StoredExportSchedule): ExportSchedule { + const [version, ivEncoded, tagEncoded, encryptedEncoded] = schedule.s3SecretAccessKey.split('.'); + if (version !== 'v1' || !ivEncoded || !tagEncoded || !encryptedEncoded) { + throw new Error('Stored export secret has an unsupported encrypted format.'); + } + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + this.encryptionKey, + Buffer.from(ivEncoded, 'base64url'), + ); + decipher.setAuthTag(Buffer.from(tagEncoded, 'base64url')); + const secret = Buffer.concat([ + decipher.update(Buffer.from(encryptedEncoded, 'base64url')), + decipher.final(), + ]).toString('utf8'); + return { ...schedule, s3SecretAccessKey: secret }; + } } export class HmacObjectStorageClient implements ObjectStorageClient {