Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/exports-security-headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 11 additions & 1 deletion docs/scheduled-exports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

---

Expand Down
9 changes: 9 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
85 changes: 76 additions & 9 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -190,16 +193,79 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
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();
Expand Down Expand Up @@ -490,6 +556,7 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
apiRepository,
developerRepository,
reportExporterService,
scheduledExportsService,
subscriptionRepository: defaultSubscriptionRepository,
}),
);
Expand Down
33 changes: 33 additions & 0 deletions src/config/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
68 changes: 68 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions src/lib/storageEndpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading