Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add corsOrigins option #1357

Merged
merged 8 commits into from
Aug 4, 2022
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
7 changes: 7 additions & 0 deletions .changeset/modern-donkeys-play.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions packages/airnode-deployer/config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 36 additions & 6 deletions packages/airnode-deployer/src/handlers/aws/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
vponline marked this conversation as resolved.
Show resolved Hide resolved
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: '' };
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
}

// 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
Expand All @@ -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": <actual_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 {
Expand All @@ -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
Expand All @@ -158,17 +188,17 @@ 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;

addMetadata({ 'Endpoint-ID': endpointId });
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": <actual_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) };
}
63 changes: 62 additions & 1 deletion packages/airnode-deployer/src/handlers/common.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) });
});
});
});
19 changes: 19 additions & 0 deletions packages/airnode-deployer/src/handlers/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
vponline marked this conversation as resolved.
Show resolved Hide resolved

return { success: false, error: { message: 'CORS origin verification failed.' } };
};
42 changes: 41 additions & 1 deletion packages/airnode-deployer/src/handlers/gcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,6 @@ components:
name: x-api-key
in: header

security:
- apiKey: []

vponline marked this conversation as resolved.
Show resolved Hide resolved
paths:
/{endpointId}:
post:
Expand All @@ -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:
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,6 @@ components:
name: x-api-key
in: header

security:
- apiKey: []

paths:
/{endpointId}:
post:
Expand All @@ -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:
Expand All @@ -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
Loading