Skip to content
Open
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
11 changes: 7 additions & 4 deletions yarn-project/aztec-node/src/bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}

Expand All @@ -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...');
Expand All @@ -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}`);
Expand Down
3 changes: 2 additions & 1 deletion yarn-project/aztec/src/cli/aztec_start_action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions yarn-project/aztec/src/cli/aztec_start_options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
2 changes: 2 additions & 0 deletions yarn-project/aztec/src/cli/aztec_start_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions yarn-project/foundation/src/config/env_var.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
17 changes: 15 additions & 2 deletions yarn-project/foundation/src/json-rpc/client/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -21,13 +25,15 @@ 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(
host: string,
body: unknown,
extraHeaders: Record<string, string> = {},
noRetry = false,
config: JsonRpcFetchConfig = {},
): Promise<{ response: any; headers: { get: (header: string) => string | null | undefined } }> {
log.debug(format(`JsonRpcClient.fetch`, host, '->', body));
let resp: Response;
Expand All @@ -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)}`;
Expand Down Expand Up @@ -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<string, string> = {}, 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,
Expand Down
52 changes: 42 additions & 10 deletions yarn-project/foundation/src/json-rpc/client/undici.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}, 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)}`;
Expand All @@ -52,14 +68,30 @@ 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);
}
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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,104 @@ describe('SafeJsonRpcServer', () => {
expect(response.status).toBe(httpCode);
};

describe('CORS', () => {
beforeEach(() => {
server = createSafeJsonRpcServer<TestStateApi>(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<TestStateApi>(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<TestStateApi>(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<TestStateApi>(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<TestStateApi>(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<TestStateApi>(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<TestStateApi>(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<TestStateApi>(testState, TestStateSchema);
Expand Down
Loading
Loading