Skip to content

Commit

Permalink
Implement Airnode whitelisting (#101)
Browse files Browse the repository at this point in the history
* Implement Airnode whitelisting

* Disallow empty whitelist

* Change 'all' to '*'

* Change schema error message

* Fix CI tests
  • Loading branch information
Siegrift authored Oct 31, 2023
1 parent 406953d commit 8b27a18
Show file tree
Hide file tree
Showing 8 changed files with 115 additions and 15 deletions.
19 changes: 19 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions packages/api/config/signed-api.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"delaySeconds": 15
}
],
"allowedAirnodes": ["0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"],
"maxBatchSize": 10,
"port": 8090,
"cache": {
Expand Down
28 changes: 22 additions & 6 deletions packages/api/src/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 });
Expand Down
20 changes: 17 additions & 3 deletions packages/api/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,20 @@ export const batchInsertData = async (requestBody: unknown): Promise<ApiResponse
);
}

// Ensure that the batch of signed that comes from a whitelisted Airnode.
const { maxBatchSize, endpoints, allowedAirnodes } = getConfig();
if (
allowedAirnodes !== '*' &&
!goValidateSchema.data.every((signedData) => 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`);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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<ApiResponse> => {
const goAirnodeAddresses = await go(async () => getAllAirnodeAddresses());
if (!goAirnodeAddresses.success) {
Expand Down
39 changes: 38 additions & 1 deletion packages/api/src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: [],
},
])
);
});
});
11 changes: 7 additions & 4 deletions packages/api/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -28,15 +34,12 @@ export const configSchema = z
cache: z.object({
maxAgeSeconds: z.number().nonnegative().int(),
}),
allowedAirnodes: allowedAirnodesSchema,
})
.strict();

export type Config = z.infer<typeof configSchema>;

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,
Expand Down
9 changes: 9 additions & 0 deletions packages/api/test/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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;
};
3 changes: 2 additions & 1 deletion packages/e2e/src/signed-api/signed-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
"port": 8090,
"cache": {
"maxAgeSeconds": 0
}
},
"allowedAirnodes": ["0xbF3137b0a7574563a23a8fC8badC6537F98197CC"]
}

0 comments on commit 8b27a18

Please sign in to comment.