diff --git a/packages/api/README.md b/packages/api/README.md index 752821b9..3c26d9c6 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 `"*"` instead of an array. + +Example: + +```jsonc +// Allows pushing signed data from any Airnode. +"allowedAirnodes": "*" +``` + +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..688c2b97 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 !== '*' && !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 "*" 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..88831123 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,33 @@ describe('env config schema', () => { ); }); }); + +describe('allowed Airnodes schema', () => { + it('accepts valid configuration', () => { + const allValid = allowedAirnodesSchema.parse('*'); + expect(allValid).toBe('*'); + + expect( + allowedAirnodesSchema.parse([ + '0xB47E3D8734780430ee6EfeF3c5407090601Dcd15', + '0xE1d8E71195606Ff69CA33A375C31fe763Db97B11', + ]) + ).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 7b88b93d..8c051747 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 ID'); + 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('*'), z.array(evmAddressSchema).nonempty()]); + 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..83510775 --- /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 = '*'; + + return config; +}; 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"] }