diff --git a/.changeset/modern-donkeys-play.md b/.changeset/modern-donkeys-play.md new file mode 100644 index 0000000000..f98c27cdc6 --- /dev/null +++ b/.changeset/modern-donkeys-play.md @@ -0,0 +1,7 @@ +--- +'@api3/airnode-deployer': minor +'@api3/airnode-examples': minor +'@api3/airnode-validator': minor +--- + +Add corsOrigins field to config.json, handle CORS checks in deployer handlers, add OPTIONS http method to terraform templates diff --git a/packages/airnode-deployer/config/config.example.json b/packages/airnode-deployer/config/config.example.json index fe2ae830d5..7ac349945d 100644 --- a/packages/airnode-deployer/config/config.example.json +++ b/packages/airnode-deployer/config/config.example.json @@ -43,12 +43,14 @@ "httpGateway": { "enabled": true, "apiKey": "${HTTP_GATEWAY_API_KEY}", - "maxConcurrency": 20 + "maxConcurrency": 20, + "corsOrigins": [] }, "httpSignedDataGateway": { "enabled": true, "apiKey": "${HTTP_SIGNED_DATA_GATEWAY_API_KEY}", - "maxConcurrency": 20 + "maxConcurrency": 20, + "corsOrigins": [] }, "logFormat": "plain", "logLevel": "INFO", diff --git a/packages/airnode-deployer/src/handlers/aws/index.ts b/packages/airnode-deployer/src/handlers/aws/index.ts index ec0e976587..e08ea4ce15 100644 --- a/packages/airnode-deployer/src/handlers/aws/index.ts +++ b/packages/airnode-deployer/src/handlers/aws/index.ts @@ -10,7 +10,7 @@ import { CallApiPayload, loadTrustedConfig, } from '@api3/airnode-node'; -import { verifyHttpSignedDataRequest, verifyHttpRequest } from '../common'; +import { verifyHttpSignedDataRequest, verifyHttpRequest, verifyRequestOrigin } from '../common'; const configFile = path.resolve(`${__dirname}/../../config-data/config.json`); const parsedConfig = loadTrustedConfig(configFile, process.env); @@ -114,6 +114,21 @@ export async function processHttpRequest( format: parsedConfig.nodeSettings.logFormat, level: parsedConfig.nodeSettings.logLevel, }); + + // Check if the request origin header is allowed in the config + const originVerification = verifyRequestOrigin( + parsedConfig.nodeSettings.httpGateway.enabled ? parsedConfig.nodeSettings.httpGateway.corsOrigins : [], + event.headers.origin + ); + if (!originVerification.success) { + return { statusCode: 400, body: JSON.stringify(originVerification.error) }; + } + + // Respond to preflight requests if the origin is allowed + if (event.httpMethod === 'OPTIONS') { + return { statusCode: 204, headers: originVerification.headers, body: '' }; + } + // The shape of the body is guaranteed by the openAPI spec const rawParameters = (JSON.parse(event.body!) as ProcessHttpRequestBody).parameters; // The "endpointId" path parameter existence is guaranteed by the openAPI spec @@ -130,11 +145,11 @@ export async function processHttpRequest( const [err, result] = await handlers.processHttpRequest(parsedConfig, endpointId, parameters); if (err) { // Returning 500 because failure here means something went wrong internally with a valid request - return { statusCode: 500, body: JSON.stringify({ message: err.toString() }) }; + return { statusCode: 500, headers: originVerification.headers, body: JSON.stringify({ message: err.toString() }) }; } // We do not want the user to see {"success": true, "data": }, but the actual data itself - return { statusCode: 200, body: JSON.stringify(result!.data) }; + return { statusCode: 200, headers: originVerification.headers, body: JSON.stringify(result!.data) }; } interface ProcessHttpSignedDataRequestBody { @@ -150,6 +165,21 @@ export async function processHttpSignedDataRequest( format: parsedConfig.nodeSettings.logFormat, level: parsedConfig.nodeSettings.logLevel, }); + + // Check if the request origin header is allowed in the config + const originVerification = verifyRequestOrigin( + parsedConfig.nodeSettings.httpGateway.enabled ? parsedConfig.nodeSettings.httpGateway.corsOrigins : [], + event.headers.origin + ); + if (!originVerification.success) { + return { statusCode: 400, body: JSON.stringify(originVerification.error) }; + } + + // Respond to preflight requests if the origin is allowed + if (event.httpMethod === 'OPTIONS') { + return { statusCode: 204, headers: originVerification.headers, body: '' }; + } + // The shape of the body is guaranteed by the openAPI spec const rawEncodedParameters = (JSON.parse(event.body!) as ProcessHttpSignedDataRequestBody).encodedParameters; // The "endpointId" path parameter existence is guaranteed by the openAPI spec @@ -158,7 +188,7 @@ export async function processHttpSignedDataRequest( const verificationResult = verifyHttpSignedDataRequest(parsedConfig, rawEncodedParameters, rawEndpointId); if (!verificationResult.success) { const { statusCode, error } = verificationResult; - return { statusCode, body: JSON.stringify(error) }; + return { statusCode, headers: originVerification.headers, body: JSON.stringify(error) }; } const { encodedParameters, endpointId } = verificationResult; @@ -166,9 +196,9 @@ export async function processHttpSignedDataRequest( const [err, result] = await handlers.processHttpSignedDataRequest(parsedConfig, endpointId, encodedParameters); if (err) { // Returning 500 because failure here means something went wrong internally with a valid request - return { statusCode: 500, body: JSON.stringify({ message: err.toString() }) }; + return { statusCode: 500, headers: originVerification.headers, body: JSON.stringify({ message: err.toString() }) }; } // We do not want the user to see {"success": true, "data": }, but the actual data itself - return { statusCode: 200, body: JSON.stringify(result!.data) }; + return { statusCode: 200, headers: originVerification.headers, body: JSON.stringify(result!.data) }; } diff --git a/packages/airnode-deployer/src/handlers/common.test.ts b/packages/airnode-deployer/src/handlers/common.test.ts index 1ea318d972..267bddb13c 100644 --- a/packages/airnode-deployer/src/handlers/common.test.ts +++ b/packages/airnode-deployer/src/handlers/common.test.ts @@ -1,7 +1,13 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { Config } from '@api3/airnode-node'; -import { verifyHttpRequest, verifyHttpSignedDataRequest } from './common'; +import { + verifyHttpRequest, + verifyHttpSignedDataRequest, + checkRequestOrigin, + verifyRequestOrigin, + buildCorsHeaders, +} from './common'; const loadConfigFixture = (): Config => // We type the result as "Config", however it will not pass validation in it's current state because the secrets are @@ -102,3 +108,58 @@ describe('verifyHttpSignedDataRequest', () => { }); }); }); + +describe('cors', () => { + const origin = 'https://origin.com'; + const notAllowedOrigin = 'https://notallowed.com'; + const allAllowedOrigin = '*'; + + describe('buildCorsHeaders', () => { + it('returns headers with input origin', () => { + const headers = buildCorsHeaders(origin); + expect(headers).toEqual({ + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'OPTIONS,POST', + 'Access-Control-Allow-Headers': 'Content-Type,x-api-key', + }); + }); + }); + + describe('checkRequestOrigin', () => { + it('returns allowed origin', () => { + const allowedOrigin = checkRequestOrigin([origin], origin); + expect(allowedOrigin).toEqual(origin); + }); + + it('returns allow all origins', () => { + const allowedOrigin = checkRequestOrigin([allAllowedOrigin], origin); + expect(allowedOrigin).toEqual(allAllowedOrigin); + }); + + it('returns undefined if no allowed origin match', () => { + const allowedOrigin = checkRequestOrigin([origin], notAllowedOrigin); + expect(allowedOrigin).toBeUndefined(); + }); + + it('returns undefined if empty allowedOrigins', () => { + const allowedOrigin = checkRequestOrigin([], notAllowedOrigin); + expect(allowedOrigin).toBeUndefined(); + }); + }); + + describe('verifyRequestOrigin', () => { + it('handles disabling cors', () => { + const originVerification = verifyRequestOrigin([], notAllowedOrigin); + expect(originVerification).toEqual({ success: false, error: { message: 'CORS origin verification failed.' } }); + }); + it('handles allow all origins', () => { + const originVerification = verifyRequestOrigin([allAllowedOrigin], notAllowedOrigin); + expect(originVerification).toEqual({ success: true, headers: buildCorsHeaders(allAllowedOrigin) }); + }); + + it('handles allowed origin', () => { + const originVerification = verifyRequestOrigin([origin], origin); + expect(originVerification).toEqual({ success: true, headers: buildCorsHeaders(origin) }); + }); + }); +}); diff --git a/packages/airnode-deployer/src/handlers/common.ts b/packages/airnode-deployer/src/handlers/common.ts index dd5782f3a6..e614177d73 100644 --- a/packages/airnode-deployer/src/handlers/common.ts +++ b/packages/airnode-deployer/src/handlers/common.ts @@ -95,3 +95,22 @@ export function verifyHttpSignedDataRequest( return { success: true, encodedParameters, endpointId: validEndpointId }; } + +export const checkRequestOrigin = (allowedOrigins: string[], origin?: string) => + allowedOrigins.find((allowedOrigin) => allowedOrigin === '*') || + (origin && allowedOrigins.find((allowedOrigin) => allowedOrigin === origin)); + +export const buildCorsHeaders = (origin: string) => ({ + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'OPTIONS,POST', + 'Access-Control-Allow-Headers': 'Content-Type,x-api-key', +}); + +export const verifyRequestOrigin = (allowedOrigins: string[], origin?: string) => { + const allowedOrigin = checkRequestOrigin(allowedOrigins, origin); + + // Return CORS headers to be used by the response if the origin is allowed + if (allowedOrigin) return { success: true, headers: buildCorsHeaders(allowedOrigin) }; + + return { success: false, error: { message: 'CORS origin verification failed.' } }; +}; diff --git a/packages/airnode-deployer/src/handlers/gcp/index.ts b/packages/airnode-deployer/src/handlers/gcp/index.ts index 242459532d..2ebea44c21 100644 --- a/packages/airnode-deployer/src/handlers/gcp/index.ts +++ b/packages/airnode-deployer/src/handlers/gcp/index.ts @@ -13,7 +13,7 @@ import { import { logger, DEFAULT_RETRY_DELAY_MS, randomHexString, setLogOptions, addMetadata } from '@api3/airnode-utilities'; import { go } from '@api3/promise-utils'; import { z } from 'zod'; -import { verifyHttpSignedDataRequest, verifyHttpRequest, VerificationResult } from '../common'; +import { verifyHttpSignedDataRequest, verifyHttpRequest, VerificationResult, verifyRequestOrigin } from '../common'; const configFile = path.resolve(`${__dirname}/../../config-data/config.json`); const parsedConfig = loadTrustedConfig(configFile, process.env); @@ -133,6 +133,26 @@ export async function processHttpRequest(req: Request, res: Response) { format: parsedConfig.nodeSettings.logFormat, level: parsedConfig.nodeSettings.logLevel, }); + + // Check if the request origin header is allowed in the config + const originVerification = verifyRequestOrigin( + parsedConfig.nodeSettings.httpGateway.enabled ? parsedConfig.nodeSettings.httpGateway.corsOrigins : [], + req.headers.origin + ); + if (!originVerification.success) { + res.status(400).send(originVerification.error); + return; + } + + // Set headers for the responses + res.set(originVerification.headers); + + // Respond to preflight requests if the origin is allowed + if (req.method === 'OPTIONS') { + res.status(204).send(''); + return; + } + const apiKeyVerification = verifyGcpApiKey(req, 'HTTP_GATEWAY_API_KEY'); if (!apiKeyVerification.success) { const { statusCode, error } = apiKeyVerification; @@ -184,6 +204,26 @@ export async function processHttpSignedDataRequest(req: Request, res: Response) format: parsedConfig.nodeSettings.logFormat, level: parsedConfig.nodeSettings.logLevel, }); + + // Check if the request origin header is allowed in the config + const originVerification = verifyRequestOrigin( + parsedConfig.nodeSettings.httpGateway.enabled ? parsedConfig.nodeSettings.httpGateway.corsOrigins : [], + req.headers.origin + ); + if (!originVerification.success) { + res.status(400).send(originVerification.error); + return; + } + + // Set headers for the responses + res.set(originVerification.headers); + + // Respond to preflight requests if the origin is allowed + if (req.method === 'OPTIONS') { + res.status(204).send(''); + return; + } + const apiKeyVerification = verifyGcpApiKey(req, 'HTTP_SIGNED_DATA_GATEWAY_API_KEY'); if (!apiKeyVerification.success) { const { statusCode, error } = apiKeyVerification; diff --git a/packages/airnode-deployer/terraform/airnode/aws/templates/httpGw.yaml.tpl b/packages/airnode-deployer/terraform/airnode/aws/templates/httpGw.yaml.tpl index 4a49814fce..71526e595f 100644 --- a/packages/airnode-deployer/terraform/airnode/aws/templates/httpGw.yaml.tpl +++ b/packages/airnode-deployer/terraform/airnode/aws/templates/httpGw.yaml.tpl @@ -59,9 +59,6 @@ components: name: x-api-key in: header -security: - - apiKey: [] - paths: /{endpointId}: post: @@ -78,6 +75,16 @@ paths: responses: "200": description: Request called + headers: + Access-Control-Allow-Headers: + schema: + type: string + Access-Control-Allow-Methods: + schema: + type: string + Access-Control-Allow-Origin: + schema: + type: string content: application/json: schema: @@ -96,3 +103,33 @@ paths: responses: default: statusCode: 200 + options: + parameters: + - $ref: "#/components/parameters/endpointId" + responses: + "204": + description: CORS preflight response + headers: + Access-Control-Allow-Headers: + schema: + type: string + Access-Control-Allow-Methods: + schema: + type: string + Access-Control-Allow-Origin: + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/EndpointResponse" + x-amazon-apigateway-integration: + passthroughBehavior: "when_no_match" + type: aws_proxy + uri: arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${proxy_lambda}/invocations + credentials: ${role} + httpMethod: POST + payloadFormatVersion: "1.0" + responses: + default: + statusCode: 200 \ No newline at end of file diff --git a/packages/airnode-deployer/terraform/airnode/aws/templates/httpSignedGw.yaml.tpl b/packages/airnode-deployer/terraform/airnode/aws/templates/httpSignedGw.yaml.tpl index 8e1524a8b2..25fdd8f47f 100644 --- a/packages/airnode-deployer/terraform/airnode/aws/templates/httpSignedGw.yaml.tpl +++ b/packages/airnode-deployer/terraform/airnode/aws/templates/httpSignedGw.yaml.tpl @@ -56,9 +56,6 @@ components: name: x-api-key in: header -security: - - apiKey: [] - paths: /{endpointId}: post: @@ -75,6 +72,16 @@ paths: responses: "200": description: Request called + headers: + Access-Control-Allow-Headers: + schema: + type: string + Access-Control-Allow-Methods: + schema: + type: string + Access-Control-Allow-Origin: + schema: + type: string content: application/json: schema: @@ -90,3 +97,33 @@ paths: responses: default: statusCode: 200 + options: + parameters: + - $ref: "#/components/parameters/endpointId" + responses: + "204": + description: CORS preflight response + headers: + Access-Control-Allow-Headers: + schema: + type: string + Access-Control-Allow-Methods: + schema: + type: string + Access-Control-Allow-Origin: + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/EndpointResponse" + x-amazon-apigateway-integration: + passthroughBehavior: "when_no_match" + type: aws_proxy + uri: arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${proxy_lambda}/invocations + credentials: ${role} + httpMethod: POST + payloadFormatVersion: "1.0" + responses: + default: + statusCode: 200 \ No newline at end of file diff --git a/packages/airnode-deployer/terraform/airnode/gcp/templates/httpGw.yaml.tpl b/packages/airnode-deployer/terraform/airnode/gcp/templates/httpGw.yaml.tpl index 85b883a21e..32f8d9fe9c 100644 --- a/packages/airnode-deployer/terraform/airnode/gcp/templates/httpGw.yaml.tpl +++ b/packages/airnode-deployer/terraform/airnode/gcp/templates/httpGw.yaml.tpl @@ -52,3 +52,24 @@ paths: x-google-backend: address: https://${region}-${project}.cloudfunctions.net/${cloud_function_name} path_translation: CONSTANT_ADDRESS + options: + operationId: corsTestEndpoint + consumes: + - application/json + produces: + - application/json + parameters: + - $ref: "#/parameters/endpointId" + - name: request + in: body + required: true + schema: + $ref: "#/definitions/EndpointRequest" + responses: + "204": + description: Request called + schema: + $ref: "#/definitions/EndpointResponse" + x-google-backend: + address: https://${region}-${project}.cloudfunctions.net/${cloud_function_name} + path_translation: CONSTANT_ADDRESS diff --git a/packages/airnode-deployer/terraform/airnode/gcp/templates/httpSignedGw.yaml.tpl b/packages/airnode-deployer/terraform/airnode/gcp/templates/httpSignedGw.yaml.tpl index f118fc75e5..37a6d67088 100644 --- a/packages/airnode-deployer/terraform/airnode/gcp/templates/httpSignedGw.yaml.tpl +++ b/packages/airnode-deployer/terraform/airnode/gcp/templates/httpSignedGw.yaml.tpl @@ -52,3 +52,24 @@ paths: x-google-backend: address: https://${region}-${project}.cloudfunctions.net/${cloud_function_name} path_translation: CONSTANT_ADDRESS + options: + operationId: corsTestEndpoint + consumes: + - application/json + produces: + - application/json + parameters: + - $ref: "#/parameters/endpointId" + - name: request + in: body + required: true + schema: + $ref: "#/definitions/EndpointRequest" + responses: + "204": + description: Request called + schema: + $ref: "#/definitions/EndpointResponse" + x-google-backend: + address: https://${region}-${project}.cloudfunctions.net/${cloud_function_name} + path_translation: CONSTANT_ADDRESS diff --git a/packages/airnode-examples/integrations/coingecko-signed-data/config.example.json b/packages/airnode-examples/integrations/coingecko-signed-data/config.example.json index 33ea3839dc..02564aba0d 100644 --- a/packages/airnode-examples/integrations/coingecko-signed-data/config.example.json +++ b/packages/airnode-examples/integrations/coingecko-signed-data/config.example.json @@ -57,7 +57,8 @@ "httpSignedDataGateway": { "enabled": true, "apiKey": "${HTTP_SIGNED_DATA_GATEWAY_API_KEY}", - "maxConcurrency": 20 + "maxConcurrency": 20, + "corsOrigins": [] }, "logFormat": "plain", "logLevel": "INFO", diff --git a/packages/airnode-examples/integrations/coingecko-signed-data/create-config.ts b/packages/airnode-examples/integrations/coingecko-signed-data/create-config.ts index 25f94fe3a3..07528d85fe 100644 --- a/packages/airnode-examples/integrations/coingecko-signed-data/create-config.ts +++ b/packages/airnode-examples/integrations/coingecko-signed-data/create-config.ts @@ -65,6 +65,7 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ enabled: true, apiKey: '${HTTP_SIGNED_DATA_GATEWAY_API_KEY}', maxConcurrency: 20, + corsOrigins: [], }, logFormat: 'plain', logLevel: 'INFO', diff --git a/packages/airnode-examples/integrations/coingecko-testable/config.example.json b/packages/airnode-examples/integrations/coingecko-testable/config.example.json index b5871e0413..0a907ba8a1 100644 --- a/packages/airnode-examples/integrations/coingecko-testable/config.example.json +++ b/packages/airnode-examples/integrations/coingecko-testable/config.example.json @@ -54,7 +54,8 @@ "httpGateway": { "enabled": true, "apiKey": "${HTTP_GATEWAY_API_KEY}", - "maxConcurrency": 20 + "maxConcurrency": 20, + "corsOrigins": [] }, "httpSignedDataGateway": { "enabled": false diff --git a/packages/airnode-examples/integrations/coingecko-testable/create-config.ts b/packages/airnode-examples/integrations/coingecko-testable/create-config.ts index 68ddb631fc..b55f62ffd4 100644 --- a/packages/airnode-examples/integrations/coingecko-testable/create-config.ts +++ b/packages/airnode-examples/integrations/coingecko-testable/create-config.ts @@ -62,6 +62,7 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ enabled: true, apiKey: '${HTTP_GATEWAY_API_KEY}', maxConcurrency: 20, + corsOrigins: [], }, httpSignedDataGateway: { enabled: false, diff --git a/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.test.ts b/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.test.ts index 6e295c6a2c..baac3d298a 100644 --- a/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.test.ts +++ b/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.test.ts @@ -536,7 +536,8 @@ describe('Gas oracle', () => { new Promise((resolve) => { setTimeout(() => { return resolve({} as any); - }, GAS_ORACLE_STRATEGY_ATTEMPT_TIMEOUT_MS); + // Set timeout to exceed attempt maximum to reduce test flakiness + }, GAS_ORACLE_STRATEGY_ATTEMPT_TIMEOUT_MS + 10); }) ); const getGasPriceSpy = jest.spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice'); @@ -545,14 +546,15 @@ describe('Gas oracle', () => { new Promise((resolve) => { setTimeout(() => { return resolve(ethers.BigNumber.from(33) as any); - }, GAS_ORACLE_STRATEGY_ATTEMPT_TIMEOUT_MS); + // Set timeout to exceed attempt maximum to reduce test flakiness + }, GAS_ORACLE_STRATEGY_ATTEMPT_TIMEOUT_MS + 10); }) ); const getBlock = jest.spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getBlock'); // Mock random backoff time jest.spyOn(global.Math, 'random').mockImplementation(() => 0.4); - // totalTimeoutMs is 10 seconds and each provider call has 2 attempts so with a 1 second delay + // totalTimeoutMs is 10 seconds and each provider call has 2 attempts so with a 2.5 second delay // we need to attempt at least 3 strategies to test exceeding the totalTimeoutMs const [_logs, gasTarget] = await gasOracle.getGasPrice(provider, { ...defaultChainOptions, diff --git a/packages/airnode-validator/src/config/config.test.ts b/packages/airnode-validator/src/config/config.test.ts index 0d8143c97c..5910825dc1 100644 --- a/packages/airnode-validator/src/config/config.test.ts +++ b/packages/airnode-validator/src/config/config.test.ts @@ -207,11 +207,13 @@ describe('nodeSettingsSchema', () => { enabled: true, apiKey: 'e83856ed-36cd-4b5f-a559-c8291e96e17e', maxConcurrency: 10, + corsOrigins: [], }, httpSignedDataGateway: { enabled: true, apiKey: 'e83856ed-36cd-4b5f-a559-c8291e96e17e', maxConcurrency: 10, + corsOrigins: [], }, }; @@ -357,6 +359,7 @@ describe('apiKey schemas', () => { enabled: true, apiKey: 'e83856ed-36cd-4b5f-a559-c8291e96e17e', maxConcurrency: 100, + corsOrigins: [], }; const heartbeat: SchemaType = { enabled: true, diff --git a/packages/airnode-validator/src/config/config.ts b/packages/airnode-validator/src/config/config.ts index 27e8c410e7..057679ebe5 100644 --- a/packages/airnode-validator/src/config/config.ts +++ b/packages/airnode-validator/src/config/config.ts @@ -171,11 +171,14 @@ export const chainConfigSchema = z export const apiKeySchema = z.string().min(30).max(120); +export const corsOriginsSchema = z.array(z.string()); + export const enabledGatewaySchema = z .object({ enabled: z.literal(true), apiKey: apiKeySchema, maxConcurrency: z.number().int(), + corsOrigins: corsOriginsSchema, }) .strict();