From 0d1cd08fd0155e2d2ebb927326404dd45b5f347b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emanuel=20Tesa=C5=99?= Date: Mon, 30 Oct 2023 09:08:06 +0100 Subject: [PATCH 1/5] Implement Airnode whitelisting --- packages/api/README.md | 19 ++++++++++++++ packages/api/config/signed-api.example.json | 1 + packages/api/src/handlers.test.ts | 28 ++++++++++++++++----- packages/api/src/handlers.ts | 20 ++++++++++++--- packages/api/src/schema.test.ts | 23 ++++++++++++++++- packages/api/src/schema.ts | 11 +++++--- packages/api/test/fixtures.ts | 9 +++++++ 7 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 packages/api/test/fixtures.ts diff --git a/packages/api/README.md b/packages/api/README.md index 752821b9..099d5382 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -134,6 +134,25 @@ The port on which the API is served. The maximum age of the cache header in seconds. +#### `allowedAirnodes` + +The list of allowed Airnode addresses. If the list is empty, no Airnode is allowed. To whitelist all Airnodes, set the +value to `"all"` instead of an array. + +Example: + +```jsonc +// Allows pushing signed data from any Airnode. +"allowedAirnodes": "all" +``` + +or + +```jsonc +// Allows pushing signed data only from the specific Airnode. +"allowedAirnodes": ["0xB47E3D8734780430ee6EfeF3c5407090601Dcd15"] +``` + ## API The API provides the following endpoints: diff --git a/packages/api/config/signed-api.example.json b/packages/api/config/signed-api.example.json index 8c7d8394..dd0a91b9 100644 --- a/packages/api/config/signed-api.example.json +++ b/packages/api/config/signed-api.example.json @@ -9,6 +9,7 @@ "delaySeconds": 15 } ], + "allowedAirnodes": ["0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"], "maxBatchSize": 10, "port": 8090, "cache": { diff --git a/packages/api/src/handlers.test.ts b/packages/api/src/handlers.test.ts index f31e4499..ce7b64bd 100644 --- a/packages/api/src/handlers.test.ts +++ b/packages/api/src/handlers.test.ts @@ -1,8 +1,6 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - import { omit } from 'lodash'; +import { getMockedConfig } from '../test/fixtures'; import { createSignedData, generateRandomWallet } from '../test/utils'; import * as cacheModule from './cache'; @@ -12,9 +10,7 @@ import { logger } from './logger'; // eslint-disable-next-line jest/no-hooks beforeEach(() => { - jest - .spyOn(configModule, 'getConfig') - .mockImplementation(() => JSON.parse(readFileSync(join(__dirname, '../config/signed-api.example.json'), 'utf8'))); + jest.spyOn(configModule, 'getConfig').mockImplementation(getMockedConfig); }); afterEach(() => { @@ -45,6 +41,26 @@ describe(batchInsertData.name, () => { expect(cacheModule.getCache()).toStrictEqual({}); }); + it('drops the batch if the airnode address is not allowed', async () => { + const config = getMockedConfig(); + config.allowedAirnodes = []; + jest.spyOn(configModule, 'getConfig').mockReturnValue(config); + const batchData = [await createSignedData()]; + + const result = await batchInsertData(batchData); + + expect(result).toStrictEqual({ + body: JSON.stringify({ message: 'Unauthorized Airnode address' }), + headers: { + 'access-control-allow-methods': '*', + 'access-control-allow-origin': '*', + 'content-type': 'application/json', + }, + statusCode: 403, + }); + expect(cacheModule.getCache()).toStrictEqual({}); + }); + it('skips signed data if there exists one with the same timestamp', async () => { const airnodeWallet = generateRandomWallet(); const storedSignedData = await createSignedData({ airnodeWallet }); diff --git a/packages/api/src/handlers.ts b/packages/api/src/handlers.ts index 56f24ae0..f88e7bcb 100644 --- a/packages/api/src/handlers.ts +++ b/packages/api/src/handlers.ts @@ -25,12 +25,20 @@ export const batchInsertData = async (requestBody: unknown): Promise allowedAirnodes.includes(signedData.airnode)) + ) { + return generateErrorResponse(403, 'Unauthorized Airnode address'); + } + // Ensure there is at least one signed data to push const batchSignedData = goValidateSchema.data; if (isEmpty(batchSignedData)) return generateErrorResponse(400, 'No signed data to push'); // Check whether the size of batch exceeds a maximum batch size - const { maxBatchSize, endpoints } = getConfig(); if (size(batchSignedData) > maxBatchSize) { return generateErrorResponse(400, `Maximum batch size (${maxBatchSize}) exceeded`); } @@ -120,6 +128,11 @@ export const getData = async (airnodeAddress: string, delaySeconds: number): Pro return generateErrorResponse(400, 'Invalid request, airnode address must be an EVM address'); } + const { allowedAirnodes } = getConfig(); + if (allowedAirnodes !== 'all' && !allowedAirnodes.includes(airnodeAddress)) { + return generateErrorResponse(403, 'Unauthorized Airnode address'); + } + const ignoreAfterTimestamp = Math.floor(Date.now() / 1000 - delaySeconds); const goReadDb = await go(async () => getAll(airnodeAddress, ignoreAfterTimestamp)); if (!goReadDb.success) { @@ -137,8 +150,9 @@ export const getData = async (airnodeAddress: string, delaySeconds: number): Pro }; }; -// Returns all airnode addresses for which there is data. Note, that the delayed endpoint may not be allowed to show -// it. +// Returns all airnode addresses for which there is data. Note, that the delayed endpoint may not be allowed to show it. +// We do not return the allowed Airnode addresses in the configuration, because the value can be set to "all" and we +// would have to scan the database anyway. export const listAirnodeAddresses = async (): Promise => { const goAirnodeAddresses = await go(async () => getAllAirnodeAddresses()); if (!goAirnodeAddresses.success) { diff --git a/packages/api/src/schema.test.ts b/packages/api/src/schema.test.ts index 7a8318cb..3ea3ca2f 100644 --- a/packages/api/src/schema.test.ts +++ b/packages/api/src/schema.test.ts @@ -4,7 +4,14 @@ import { join } from 'node:path'; import dotenv from 'dotenv'; import { ZodError } from 'zod'; -import { configSchema, endpointSchema, endpointsSchema, envBooleanSchema, envConfigSchema } from './schema'; +import { + allowedAirnodesSchema, + configSchema, + endpointSchema, + endpointsSchema, + envBooleanSchema, + envConfigSchema, +} from './schema'; describe('endpointSchema', () => { it('validates urlPath', () => { @@ -114,3 +121,17 @@ describe('env config schema', () => { ); }); }); + +describe('allowed Airnodes schema', () => { + it('accepts valid configuration', () => { + const allValid = allowedAirnodesSchema.parse('all'); + expect(allValid).toBe('all'); + + expect( + allowedAirnodesSchema.parse([ + '0xB47E3D8734780430ee6EfeF3c5407090601Dcd15', + '0xE1d8E71195606Ff69CA33A375C31fe763Db97B11', + ]) + ).toStrictEqual(['0xB47E3D8734780430ee6EfeF3c5407090601Dcd15', '0xE1d8E71195606Ff69CA33A375C31fe763Db97B11']); + }); +}); diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 7b88b93d..d389ab08 100644 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2,6 +2,10 @@ import { type LogFormat, logFormatOptions, logLevelOptions, type LogLevel } from import { uniqBy } from 'lodash'; import { z } from 'zod'; +export const evmAddressSchema = z.string().regex(/^0x[\dA-Fa-f]{40}$/, 'Must be a valid EVM address'); + +export const evmIdSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/, 'Must be a valid EVM hash'); + export const endpointSchema = z .object({ urlPath: z @@ -20,6 +24,8 @@ export const endpointsSchema = z 'Each "urlPath" of an endpoint must be unique' ); +export const allowedAirnodesSchema = z.union([z.literal('all'), z.array(evmAddressSchema)]); + export const configSchema = z .object({ endpoints: endpointsSchema, @@ -28,15 +34,12 @@ export const configSchema = z cache: z.object({ maxAgeSeconds: z.number().nonnegative().int(), }), + allowedAirnodes: allowedAirnodesSchema, }) .strict(); export type Config = z.infer; -export const evmAddressSchema = z.string().regex(/^0x[\dA-Fa-f]{40}$/, 'Must be a valid EVM address'); - -export const evmIdSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/, 'Must be a valid EVM hash'); - export const signedDataSchema = z.object({ airnode: evmAddressSchema, templateId: evmIdSchema, diff --git a/packages/api/test/fixtures.ts b/packages/api/test/fixtures.ts new file mode 100644 index 00000000..c81b665a --- /dev/null +++ b/packages/api/test/fixtures.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const getMockedConfig = () => { + const config = JSON.parse(readFileSync(join(__dirname, '../config/signed-api.example.json'), 'utf8')); + config.allowedAirnodes = 'all'; + + return config; +}; From 605c77f8a7d7864a81855936a578e953633f1036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emanuel=20Tesa=C5=99?= Date: Mon, 30 Oct 2023 09:45:10 +0100 Subject: [PATCH 2/5] Disallow empty whitelist --- packages/api/src/schema.test.ts | 16 ++++++++++++++++ packages/api/src/schema.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/api/src/schema.test.ts b/packages/api/src/schema.test.ts index 3ea3ca2f..a9a85017 100644 --- a/packages/api/src/schema.test.ts +++ b/packages/api/src/schema.test.ts @@ -134,4 +134,20 @@ describe('allowed Airnodes schema', () => { ]) ).toStrictEqual(['0xB47E3D8734780430ee6EfeF3c5407090601Dcd15', '0xE1d8E71195606Ff69CA33A375C31fe763Db97B11']); }); + + it('disallows empty list', () => { + expect(() => allowedAirnodesSchema.parse([])).toThrow( + new ZodError([ + { + code: 'too_small', + minimum: 1, + type: 'array', + inclusive: true, + exact: false, + message: 'Array must contain at least 1 element(s)', + path: [], + }, + ]) + ); + }); }); diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index d389ab08..4aa55b56 100644 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -24,7 +24,7 @@ export const endpointsSchema = z 'Each "urlPath" of an endpoint must be unique' ); -export const allowedAirnodesSchema = z.union([z.literal('all'), z.array(evmAddressSchema)]); +export const allowedAirnodesSchema = z.union([z.literal('all'), z.array(evmAddressSchema).nonempty()]); export const configSchema = z .object({ From a79391e384969eade3cfeaff38319774c54f6aeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emanuel=20Tesa=C5=99?= Date: Tue, 31 Oct 2023 13:00:59 +0100 Subject: [PATCH 3/5] Change 'all' to '*' --- packages/api/README.md | 4 ++-- packages/api/src/handlers.ts | 6 +++--- packages/api/src/schema.test.ts | 4 ++-- packages/api/src/schema.ts | 2 +- packages/api/test/fixtures.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/api/README.md b/packages/api/README.md index 099d5382..3c26d9c6 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -137,13 +137,13 @@ The maximum age of the cache header in seconds. #### `allowedAirnodes` The list of allowed Airnode addresses. If the list is empty, no Airnode is allowed. To whitelist all Airnodes, set the -value to `"all"` instead of an array. +value to `"*"` instead of an array. Example: ```jsonc // Allows pushing signed data from any Airnode. -"allowedAirnodes": "all" +"allowedAirnodes": "*" ``` or diff --git a/packages/api/src/handlers.ts b/packages/api/src/handlers.ts index f88e7bcb..688c2b97 100644 --- a/packages/api/src/handlers.ts +++ b/packages/api/src/handlers.ts @@ -28,7 +28,7 @@ export const batchInsertData = async (requestBody: unknown): Promise allowedAirnodes.includes(signedData.airnode)) ) { return generateErrorResponse(403, 'Unauthorized Airnode address'); @@ -129,7 +129,7 @@ export const getData = async (airnodeAddress: string, delaySeconds: number): Pro } const { allowedAirnodes } = getConfig(); - if (allowedAirnodes !== 'all' && !allowedAirnodes.includes(airnodeAddress)) { + if (allowedAirnodes !== '*' && !allowedAirnodes.includes(airnodeAddress)) { return generateErrorResponse(403, 'Unauthorized Airnode address'); } @@ -151,7 +151,7 @@ export const getData = async (airnodeAddress: string, delaySeconds: number): Pro }; // Returns all airnode addresses for which there is data. Note, that the delayed endpoint may not be allowed to show it. -// We do not return the allowed Airnode addresses in the configuration, because the value can be set to "all" and we +// We do not return the allowed Airnode addresses in the configuration, because the value can be set to "*" and we // would have to scan the database anyway. export const listAirnodeAddresses = async (): Promise => { const goAirnodeAddresses = await go(async () => getAllAirnodeAddresses()); diff --git a/packages/api/src/schema.test.ts b/packages/api/src/schema.test.ts index a9a85017..88831123 100644 --- a/packages/api/src/schema.test.ts +++ b/packages/api/src/schema.test.ts @@ -124,8 +124,8 @@ describe('env config schema', () => { describe('allowed Airnodes schema', () => { it('accepts valid configuration', () => { - const allValid = allowedAirnodesSchema.parse('all'); - expect(allValid).toBe('all'); + const allValid = allowedAirnodesSchema.parse('*'); + expect(allValid).toBe('*'); expect( allowedAirnodesSchema.parse([ diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 4aa55b56..4b3d060a 100644 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -24,7 +24,7 @@ export const endpointsSchema = z 'Each "urlPath" of an endpoint must be unique' ); -export const allowedAirnodesSchema = z.union([z.literal('all'), z.array(evmAddressSchema).nonempty()]); +export const allowedAirnodesSchema = z.union([z.literal('*'), z.array(evmAddressSchema).nonempty()]); export const configSchema = z .object({ diff --git a/packages/api/test/fixtures.ts b/packages/api/test/fixtures.ts index c81b665a..83510775 100644 --- a/packages/api/test/fixtures.ts +++ b/packages/api/test/fixtures.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; export const getMockedConfig = () => { const config = JSON.parse(readFileSync(join(__dirname, '../config/signed-api.example.json'), 'utf8')); - config.allowedAirnodes = 'all'; + config.allowedAirnodes = '*'; return config; }; From a4fd602e7b04928d787a688da16dcaec8557e4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emanuel=20Tesa=C5=99?= Date: Tue, 31 Oct 2023 13:31:56 +0100 Subject: [PATCH 4/5] Change schema error message --- packages/api/src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 4b3d060a..8c051747 100644 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; export const evmAddressSchema = z.string().regex(/^0x[\dA-Fa-f]{40}$/, 'Must be a valid EVM address'); -export const evmIdSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/, 'Must be a valid EVM hash'); +export const evmIdSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/, 'Must be a valid EVM ID'); export const endpointSchema = z .object({ From 77b7a994691da25d35acf0d5158411176e7a982f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emanuel=20Tesa=C5=99?= Date: Tue, 31 Oct 2023 14:05:55 +0100 Subject: [PATCH 5/5] Fix CI tests --- packages/e2e/src/signed-api/signed-api.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/e2e/src/signed-api/signed-api.json b/packages/e2e/src/signed-api/signed-api.json index 9d36ac72..f6751e1d 100644 --- a/packages/e2e/src/signed-api/signed-api.json +++ b/packages/e2e/src/signed-api/signed-api.json @@ -13,5 +13,6 @@ "port": 8090, "cache": { "maxAgeSeconds": 0 - } + }, + "allowedAirnodes": ["0xbF3137b0a7574563a23a8fC8badC6537F98197CC"] }