diff --git a/yarn-project/aztec-node/src/bin/index.ts b/yarn-project/aztec-node/src/bin/index.ts index f10775110b7e..fb197322ed85 100644 --- a/yarn-project/aztec-node/src/bin/index.ts +++ b/yarn-project/aztec-node/src/bin/index.ts @@ -5,6 +5,7 @@ import { startHttpRpcServer, } from '@aztec/foundation/json-rpc/server'; import { createLogger } from '@aztec/foundation/log'; +import { getRpcCorsAllowedOrigins } from '@aztec/stdlib/config'; import { getOtelJsonRpcDiagnosticsMiddleware, getOtelJsonRpcPropagationMiddleware, @@ -25,9 +26,7 @@ const logger = createLogger('node'); /** * Creates the node from provided config */ -async function createAndDeployAztecNode() { - const aztecNodeConfig: AztecNodeConfig = { ...getConfigEnvVars() }; - +async function createAndDeployAztecNode(aztecNodeConfig: AztecNodeConfig) { return await createAztecNodeService(aztecNodeConfig); } @@ -37,7 +36,8 @@ async function createAndDeployAztecNode() { async function main() { logger.info(`Setting up Aztec Node...`); - const aztecNode = await createAndDeployAztecNode(); + const aztecNodeConfig: AztecNodeConfig = { ...getConfigEnvVars() }; + const aztecNode = await createAndDeployAztecNode(aztecNodeConfig); const shutdown = async () => { logger.info('Shutting down...'); @@ -55,6 +55,9 @@ async function main() { const rpcServer = createNamespacedSafeJsonRpcServer(services, { diagnostic: getOtelJsonRpcDiagnosticsMiddleware(), middlewares: [getOtelJsonRpcServerMetricsMiddleware(), getOtelJsonRpcPropagationMiddleware()], + maxBatchSize: aztecNodeConfig.rpcMaxBatchSize, + maxBodySizeBytes: aztecNodeConfig.rpcMaxBodySize, + corsAllowedOrigins: getRpcCorsAllowedOrigins(aztecNodeConfig), }); await startHttpRpcServer(rpcServer, { port: +AZTEC_NODE_PORT, apiPrefix: API_PREFIX }); logger.info(`Aztec Node JSON-RPC Server listening on port ${AZTEC_NODE_PORT}`); diff --git a/yarn-project/aztec/src/cli/aztec_start_action.ts b/yarn-project/aztec/src/cli/aztec_start_action.ts index 0cbfeae7eafa..21e6edadb87c 100644 --- a/yarn-project/aztec/src/cli/aztec_start_action.ts +++ b/yarn-project/aztec/src/cli/aztec_start_action.ts @@ -7,7 +7,7 @@ import { startHttpRpcServer, } from '@aztec/foundation/json-rpc/server'; import type { LogFn, Logger } from '@aztec/foundation/log'; -import type { ChainConfig } from '@aztec/stdlib/config'; +import { type ChainConfig, getRpcCorsAllowedOrigins } from '@aztec/stdlib/config'; import { getPackageVersion } from '@aztec/stdlib/update-checker'; import { getVersioningMiddleware } from '@aztec/stdlib/versioning'; import { @@ -104,6 +104,7 @@ export async function aztecStart(options: any, userLog: LogFn, debugLogger: Logg ], maxBatchSize: options.rpcMaxBatchSize, maxBodySizeBytes: options.rpcMaxBodySize, + corsAllowedOrigins: getRpcCorsAllowedOrigins(options), }); const { port } = await startHttpRpcServer(rpcServer, { port: options.port }); debugLogger.info(`Aztec Server listening on port ${port}`, versions); diff --git a/yarn-project/aztec/src/cli/aztec_start_options.test.ts b/yarn-project/aztec/src/cli/aztec_start_options.test.ts index befe8bedf91a..2b3f0295ad44 100644 --- a/yarn-project/aztec/src/cli/aztec_start_options.test.ts +++ b/yarn-project/aztec/src/cli/aztec_start_options.test.ts @@ -58,6 +58,21 @@ describe('aztec_start_options commander integration', () => { expect(opts.l1RpcUrls).toEqual(['http://a', 'http://b']); }); + it('parses the RPC CORS allowed origins flag', () => { + const cmd = buildCommandWith(['API']); + cmd.parse(['node', 'cli', '--rpc-cors-allowed-origins', 'https://app1.example.com, https://app2.example.com']); + + expect(cmd.opts().rpcCorsAllowedOrigins).toEqual(['https://app1.example.com', 'https://app2.example.com']); + }); + + it('enables public credentialed CORS from the environment', () => { + process.env.RPC_CORS_ALLOW_ANY_ORIGIN = 'true'; + const cmd = buildCommandWith(['API']); + cmd.parse(['node', 'cli']); + + expect(cmd.opts().rpcCorsAllowAnyOrigin).toBe(true); + }); + it('parses SecretValue arrays from env for ETHEREUM consensus keys', () => { process.env.L1_CONSENSUS_HOST_API_KEYS = 'k1, k2'; const cmd = buildCommandWith(['ETHEREUM']); diff --git a/yarn-project/aztec/src/cli/aztec_start_options.ts b/yarn-project/aztec/src/cli/aztec_start_options.ts index 7f66864ea834..2e5930630564 100644 --- a/yarn-project/aztec/src/cli/aztec_start_options.ts +++ b/yarn-project/aztec/src/cli/aztec_start_options.ts @@ -190,6 +190,8 @@ export const aztecStartOptions: { [key: string]: AztecStartOption[] } = { }, configToFlag('--rpcMaxBatchSize', nodeRpcConfigMappings.rpcMaxBatchSize), configToFlag('--rpcMaxBodySize', nodeRpcConfigMappings.rpcMaxBodySize), + configToFlag('--rpc-cors-allowed-origins', nodeRpcConfigMappings.rpcCorsAllowedOrigins), + configToFlag('--rpc-cors-allow-any-origin', nodeRpcConfigMappings.rpcCorsAllowAnyOrigin), ], ETHEREUM: [ configToFlag('--l1-chain-id', l1ReaderConfigMappings.l1ChainId), diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 645525138732..fb3fc8f2707a 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -218,6 +218,8 @@ export type EnvVar = | 'PXE_SYNC_CHAIN_TIP' | 'RPC_MAX_BATCH_SIZE' | 'RPC_MAX_BODY_SIZE' + | 'RPC_CORS_ALLOW_ANY_ORIGIN' + | 'RPC_CORS_ALLOWED_ORIGINS' | 'RPC_SIMULATE_PUBLIC_MAX_GAS_LIMIT' | 'RPC_SIMULATE_PUBLIC_MAX_DEBUG_LOG_MEMORY_READS' | 'OFFENSE_COLLECTION_ENABLED' diff --git a/yarn-project/foundation/src/json-rpc/client/fetch.ts b/yarn-project/foundation/src/json-rpc/client/fetch.ts index 21dcebe7fdc9..6401f93f28cb 100644 --- a/yarn-project/foundation/src/json-rpc/client/fetch.ts +++ b/yarn-project/foundation/src/json-rpc/client/fetch.ts @@ -13,6 +13,10 @@ export type JsonRpcFetch = ( noRetry?: boolean, ) => Promise<{ response: any; headers: { get: (header: string) => string | null | undefined } }>; +export type JsonRpcFetchConfig = { + credentials?: RequestCredentials; +}; + /** * A normal fetch function that does not retry. * Alternatives are a fetch function with retries, or a mocked fetch. @@ -21,6 +25,7 @@ export type JsonRpcFetch = ( * @param body - The RPC payload. * @param noRetry - Whether to throw a `NoRetryError` in case the response is a 5xx error and the body contains an error * message (see `retry` function for more details). + * @param config - Fetch transport configuration. * @returns The parsed JSON response, or throws an error. */ export async function defaultFetch( @@ -28,6 +33,7 @@ export async function defaultFetch( body: unknown, extraHeaders: Record = {}, noRetry = false, + config: JsonRpcFetchConfig = {}, ): Promise<{ response: any; headers: { get: (header: string) => string | null | undefined } }> { log.debug(format(`JsonRpcClient.fetch`, host, '->', body)); let resp: Response; @@ -36,6 +42,7 @@ export async function defaultFetch( method: 'POST', body: jsonStringify(body), headers: { 'content-type': 'application/json', ...extraHeaders }, + credentials: config.credentials ?? 'omit', }); } catch (err) { const errorMessage = `Error fetching from host ${host}: ${inspect(err)}`; @@ -70,12 +77,18 @@ export async function defaultFetch( * @param retries - Sequence of intervals (in seconds) to retry. * @param noRetry - Whether to stop retries on server errors. * @param log - Optional logger for logging attempts. + * @param config - Fetch transport configuration. * @returns A fetch function. */ -export function makeFetch(retries: number[], defaultNoRetry: boolean, log?: Logger): typeof defaultFetch { +export function makeFetch( + retries: number[], + defaultNoRetry: boolean, + log?: Logger, + config: JsonRpcFetchConfig = {}, +): JsonRpcFetch { return async (host: string, body: unknown, extraHeaders: Record = {}, noRetry?: boolean) => { return await retry( - () => defaultFetch(host, body, extraHeaders, noRetry ?? defaultNoRetry), + () => defaultFetch(host, body, extraHeaders, noRetry ?? defaultNoRetry, config), `JsonRpcClient request to ${host}`, makeBackoff(retries), log, diff --git a/yarn-project/foundation/src/json-rpc/client/undici.ts b/yarn-project/foundation/src/json-rpc/client/undici.ts index 3a1658b26105..655e985c372c 100644 --- a/yarn-project/foundation/src/json-rpc/client/undici.ts +++ b/yarn-project/foundation/src/json-rpc/client/undici.ts @@ -17,24 +17,40 @@ const COMPRESSION_THRESHOLD = 1024; export { Agent }; -export function makeUndiciFetch(client = new Agent()): JsonRpcFetch { +/** Cookie storage. */ +export interface CookieJar { + getCookieString(url: string): string; + setCookie(cookie: string, url: string): void; +} + +export function makeUndiciFetch(client: Dispatcher = new Agent(), cookieJar?: CookieJar): JsonRpcFetch { return async (host: string, body: unknown, extraHeaders: Record = {}, noRetry = false) => { log.trace(`JsonRpcClient.fetch: ${host}`, { host, body }); + const requestUrl = new URL(host); + requestUrl.hash = ''; let resp: Dispatcher.ResponseData; try { + const headers = new Headers(); + for (const [name, value] of Object.entries(extraHeaders)) { + headers.append(name, value); + } + const cookie = cookieJar?.getCookieString(requestUrl.href); + if (cookie) { + headers.append('cookie', cookie); + } const jsonBody = Buffer.from(jsonStringify(body)); const shouldCompress = jsonBody.length >= COMPRESSION_THRESHOLD; + headers.set('content-type', 'application/json'); + if (shouldCompress) { + headers.set('content-encoding', 'gzip'); + } + headers.set('accept-encoding', 'gzip'); resp = await client.request({ method: 'POST', - origin: new URL(host), - path: '/', + origin: requestUrl.origin, + path: `${requestUrl.pathname}${requestUrl.search}`, body: shouldCompress ? await gzip(jsonBody) : jsonBody, - headers: { - ...extraHeaders, - 'content-type': 'application/json', - ...(shouldCompress && { 'content-encoding': 'gzip' }), - 'accept-encoding': 'gzip', - }, + headers: [...headers.entries()].flatMap(([name, value]) => [name, value]), }); } catch (err) { const errorMessage = `Error fetching from host ${host}: ${String(err)}`; @@ -52,7 +68,6 @@ export function makeUndiciFetch(client = new Agent()): JsonRpcFetch { } else { responseText = await resp.body.text(); } - responseJson = JSON.parse(responseText); } catch { if (!responseOk) { throw new Error('HTTP ' + resp.statusCode); @@ -60,6 +75,23 @@ export function makeUndiciFetch(client = new Agent()): JsonRpcFetch { throw new Error(`Failed to parse body as JSON. encoding: ${contentEncoding}, body: ${responseText!}`); } + if (cookieJar) { + const setCookieHeaders = resp.headers['set-cookie']; + const cookies = typeof setCookieHeaders === 'string' ? [setCookieHeaders] : (setCookieHeaders ?? []); + for (const cookie of cookies) { + cookieJar.setCookie(cookie, requestUrl.href); + } + } + + try { + responseJson = JSON.parse(responseText); + } catch { + if (!responseOk) { + throw new Error('HTTP ' + resp.statusCode); + } + throw new Error(`Failed to parse body as JSON. encoding: ${contentEncoding}, body: ${responseText}`); + } + if (!responseOk) { const errorMessage = `Error ${resp.statusCode} response from server ${host}: ${responseJson}`; if (noRetry || (resp.statusCode >= 400 && resp.statusCode < 500)) { diff --git a/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.test.ts b/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.test.ts index c584e41eeba7..04c0ab641f86 100644 --- a/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.test.ts +++ b/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.test.ts @@ -30,6 +30,104 @@ describe('SafeJsonRpcServer', () => { expect(response.status).toBe(httpCode); }; + describe('CORS', () => { + beforeEach(() => { + server = createSafeJsonRpcServer(testState, TestStateSchema); + }); + + it('preserves wildcard non-credentialed CORS by default', async () => { + const response = await send({ method: 'count', params: [] }).set('origin', 'https://app.example.com'); + + expect(response.headers['access-control-allow-origin']).toBe('*'); + expect(response.headers['access-control-allow-credentials']).toBeUndefined(); + }); + + it('allows credentialed requests from configured origins', async () => { + server = createSafeJsonRpcServer(testState, TestStateSchema, { + corsAllowedOrigins: ['https://app.example.com/'], + }); + + const response = await send({ method: 'count', params: [] }).set('origin', 'https://app.example.com'); + + expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com'); + expect(response.headers['access-control-allow-credentials']).toBe('true'); + expect(response.headers.vary).toContain('Origin'); + }); + + it('reflects any request origin when the wildcard policy is configured', async () => { + server = createSafeJsonRpcServer(testState, TestStateSchema, { + corsAllowedOrigins: ['*'], + }); + + const response = await send({ method: 'count', params: [] }).set('origin', 'https://public-app.example.com'); + + expect(response.headers['access-control-allow-origin']).toBe('https://public-app.example.com'); + expect(response.headers['access-control-allow-credentials']).toBe('true'); + expect(response.headers.vary).toContain('Origin'); + }); + + it('does not allow requests from origins outside the allowlist', async () => { + server = createSafeJsonRpcServer(testState, TestStateSchema, { + corsAllowedOrigins: ['https://app.example.com'], + }); + + const response = await send({ method: 'count', params: [] }).set('origin', 'https://other.example.com'); + + expect(response.status).toBe(200); + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + expect(response.headers['access-control-allow-credentials']).toBeUndefined(); + }); + + it('rejects invalid configured origins', () => { + expect(() => + createSafeJsonRpcServer(testState, TestStateSchema, { + corsAllowedOrigins: ['https://app.example.com/path'], + }), + ).toThrow('CORS allowed origin must not include credentials, a path, query parameters, or a fragment'); + }); + + it('handles allowed preflight requests before additional middleware', async () => { + let middlewareCalled = false; + server = createSafeJsonRpcServer(testState, TestStateSchema, { + corsAllowedOrigins: ['https://app.example.com'], + middlewares: [ + async (_ctx, next) => { + middlewareCalled = true; + await next(); + }, + ], + }); + + const response = await request(server.getApp().callback()) + .options('/') + .set('origin', 'https://app.example.com') + .set('access-control-request-method', 'POST') + .set('access-control-request-headers', 'content-type,x-api-key'); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com'); + expect(response.headers['access-control-allow-credentials']).toBe('true'); + expect(response.headers['access-control-allow-headers']).toBe('content-type,x-api-key'); + expect(middlewareCalled).toBe(false); + }); + + it('reflects any request origin on preflight under the wildcard policy', async () => { + server = createSafeJsonRpcServer(testState, TestStateSchema, { + corsAllowedOrigins: ['*'], + }); + + const response = await request(server.getApp().callback()) + .options('/') + .set('origin', 'https://public-app.example.com') + .set('access-control-request-method', 'POST') + .set('access-control-request-headers', 'content-type,x-api-key'); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-origin']).toBe('https://public-app.example.com'); + expect(response.headers['access-control-allow-credentials']).toBe('true'); + }); + }); + describe('single', () => { beforeEach(() => { server = createSafeJsonRpcServer(testState, TestStateSchema); diff --git a/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.ts b/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.ts index a3d177ea2f47..6a3098fc9dd0 100644 --- a/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.ts +++ b/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.ts @@ -39,14 +39,37 @@ export type SafeJsonRpcServerConfig = { http200OnError: boolean; /** The maximum body size the server will accept */ maxBodySizeBytes: string; + /** Origins allowed to make credentialed cross-origin requests. An empty list preserves wildcard CORS. */ + corsAllowedOrigins?: string[]; }; -const defaultServerConfig: SafeJsonRpcServerConfig = { +type ResolvedSafeJsonRpcServerConfig = Omit & { + corsAllowedOrigins: string[]; +}; + +const defaultServerConfig: ResolvedSafeJsonRpcServerConfig = { http200OnError: false, maxBatchSize: 100, maxBodySizeBytes: '1mb', + corsAllowedOrigins: [], }; +function normalizeCorsOrigin(origin: string): string { + if (origin === '*') { + return origin; + } + const url = new URL(origin); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`Invalid CORS origin protocol: ${origin}`); + } + if (url.username || url.password || (url.pathname !== '' && url.pathname !== '/') || url.search || url.hash) { + throw new Error( + `CORS allowed origin must not include credentials, a path, query parameters, or a fragment: ${origin}`, + ); + } + return url.origin; +} + export class SafeJsonRpcServer { /** * The HTTP server accepting remote requests. @@ -54,7 +77,7 @@ export class SafeJsonRpcServer { */ private httpServer?: http.Server; - private config: SafeJsonRpcServerConfig; + private config: ResolvedSafeJsonRpcServerConfig; constructor( /** The proxy object to delegate requests to */ @@ -70,6 +93,7 @@ export class SafeJsonRpcServer { private log = createLogger('json-rpc:server'), ) { this.config = { ...defaultServerConfig, ...config }; + this.config.corsAllowedOrigins = this.config.corsAllowedOrigins.map(normalizeCorsOrigin); // handle empty string if (!this.config.maxBodySizeBytes) { @@ -127,6 +151,33 @@ export class SafeJsonRpcServer { app.use(compress({ br: false })); app.use(jsonResponse); + if (this.config.corsAllowedOrigins.length === 0) { + app.use(cors()); + } else { + const allowedOrigins = new Set(this.config.corsAllowedOrigins); + const allowAnyOrigin = allowedOrigins.has('*'); + app.use( + cors({ + origin: ctx => { + const origin = ctx.get('Origin'); + if (!origin) { + return ''; + } + + if (allowAnyOrigin) { + return origin; + } + + if (allowedOrigins.has(origin)) { + return origin; + } + + return ''; + }, + credentials: true, + }), + ); + } for (const middleware of this.extraMiddlewares) { app.use(middleware); } @@ -137,7 +188,6 @@ export class SafeJsonRpcServer { enableTypes: ['json'], }), ); - app.use(cors()); app.use(router.routes()); app.use(router.allowedMethods()); diff --git a/yarn-project/stdlib/src/config/node-rpc-config.ts b/yarn-project/stdlib/src/config/node-rpc-config.ts index d1e9313941c6..d5427021abbd 100644 --- a/yarn-project/stdlib/src/config/node-rpc-config.ts +++ b/yarn-project/stdlib/src/config/node-rpc-config.ts @@ -1,4 +1,4 @@ -import { type ConfigMappingsType, numberConfigHelper } from '@aztec/foundation/config'; +import { type ConfigMappingsType, booleanConfigHelper, numberConfigHelper } from '@aztec/foundation/config'; import { DEFAULT_MAX_DEBUG_LOG_MEMORY_READS } from '../avm/avm.js'; @@ -24,6 +24,21 @@ export const nodeRpcConfigMappings: ConfigMappingsType = { description: 'Maximum allowed batch size for JSON RPC batch requests.', defaultValue: '1mb', }, + rpcCorsAllowedOrigins: { + env: 'RPC_CORS_ALLOWED_ORIGINS', + description: 'Origins allowed to make credentialed cross-origin JSON RPC requests, separated by commas.', + parseEnv: (value: string) => + value + .split(',') + .map(origin => origin.trim()) + .filter(Boolean), + defaultValue: [], + }, + rpcCorsAllowAnyOrigin: { + env: 'RPC_CORS_ALLOW_ANY_ORIGIN', + description: 'Allow credentialed cross-origin JSON RPC requests from any origin.', + ...booleanConfigHelper(false), + }, }; export type NodeRPCConfig = { @@ -35,4 +50,15 @@ export type NodeRPCConfig = { rpcMaxBatchSize: number; /** The maximum body size the RPC server will accept */ rpcMaxBodySize: string; + /** Origins allowed to make credentialed cross-origin requests to the RPC server. */ + rpcCorsAllowedOrigins: string[]; + /** Whether to allow credentialed cross-origin requests from any origin. */ + rpcCorsAllowAnyOrigin: boolean; }; + +/** Resolves the CORS origin policy for an RPC server. */ +export function getRpcCorsAllowedOrigins( + config: Pick, +): string[] { + return config.rpcCorsAllowAnyOrigin ? ['*'] : config.rpcCorsAllowedOrigins; +}