Skip to content

Commit d3aa269

Browse files
authored
Add airnode address to POST endpoint as a path parameter (#189)
* Add airnode address to POST endpoint as a path parameter * Check authentication first * Update package name in READMEs * Fix typo
1 parent 61ea35d commit d3aa269

File tree

7 files changed

+83
-43
lines changed

7 files changed

+83
-43
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A monorepo for managing signed data. Consists of:
44

5-
- [api](./packages/signed-api/README.md) - A service for storing and accessing signed data. It provides endpoints to
5+
- [signed-api](./packages/signed-api/README.md) - A service for storing and accessing signed data. It provides endpoints to
66
handle signed data for a specific airnode.
77
- [airnode-feed](./packages/airnode-feed/README.md) - A service for pushing data provider signed data.
88
- [e2e](./packages/e2e/README.md) - End to end test utilizing Mock API, Airnode feed and signed API.

packages/airnode-feed/src/api-requests/signed-api.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe(pushSignedData.name, () => {
3838
);
3939
jest.spyOn(stateModule, 'getState').mockReturnValue(state);
4040
jest.spyOn(logger, 'warn');
41-
jest.spyOn(axios, 'post').mockResolvedValue({ youHaveNotThoughAboutThisDidYou: 'yes-I-did' });
41+
jest.spyOn(axios, 'post').mockResolvedValue({ youHaveNotThoughtAboutThisDidYou: 'yes-I-did' });
4242

4343
const response = await pushSignedData(config.triggers.signedApiUpdates[0]!);
4444

packages/airnode-feed/src/api-requests/signed-api.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const pushSignedData = async (group: SignedApiUpdate) => {
4545
const signedApi = signedApis.find((a) => a.name === signedApiName)!;
4646
const goAxiosRequest = await go<Promise<unknown>, AxiosError>(async () => {
4747
logger.debug('Posting batch payload.', { batchPayload });
48-
const axiosResponse = await axios.post(signedApi.url, batchPayload, {
48+
const axiosResponse = await axios.post(new URL(airnode, signedApi.url).href, batchPayload, {
4949
headers: {
5050
'Content-Type': 'application/json',
5151
...(signedApi.authToken ? { Authorization: `Bearer ${signedApi.authToken}` } : {}),

packages/signed-api/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# api
1+
# signed-api
22

33
> A service for storing and accessing signed data. It provides endpoints to handle signed data for a specific airnode.
44

packages/signed-api/src/handlers.test.ts

+46-14
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe(batchInsertData.name, () => {
2323
it('validates signature', async () => {
2424
const invalidData = await createSignedData({ signature: '0xInvalid' });
2525

26-
const result = await batchInsertData(undefined, [invalidData]);
26+
const result = await batchInsertData(undefined, [invalidData], invalidData.airnode);
2727

2828
expect(result).toStrictEqual({
2929
body: JSON.stringify({
@@ -47,7 +47,7 @@ describe(batchInsertData.name, () => {
4747
const data = await createSignedData();
4848
const invalidData = { ...data, beaconId: deriveBeaconId(data.airnode, generateRandomBytes(32)) };
4949

50-
const result = await batchInsertData(undefined, [invalidData]);
50+
const result = await batchInsertData(undefined, [invalidData], invalidData.airnode);
5151

5252
expect(result).toStrictEqual({
5353
body: JSON.stringify({
@@ -74,7 +74,7 @@ describe(batchInsertData.name, () => {
7474
);
7575
const batchData = [await createSignedData({ airnodeWallet })];
7676

77-
const result = await batchInsertData(undefined, batchData);
77+
const result = await batchInsertData(undefined, batchData, airnodeWallet.address);
7878

7979
expect(result).toStrictEqual({
8080
body: JSON.stringify({
@@ -109,7 +109,7 @@ describe(batchInsertData.name, () => {
109109
];
110110
jest.spyOn(logger, 'debug');
111111

112-
const result = await batchInsertData(undefined, batchData);
112+
const result = await batchInsertData(undefined, batchData, airnodeWallet.address);
113113

114114
expect(result).toStrictEqual({
115115
body: JSON.stringify({ count: 1, skipped: 1 }),
@@ -124,14 +124,14 @@ describe(batchInsertData.name, () => {
124124
});
125125

126126
it('rejects a batch if there is a beacon with timestamp too far in the future', async () => {
127-
const batchData = [await createSignedData({ timestamp: (Math.floor(Date.now() / 1000) + 60 * 60 * 2).toString() })];
127+
const invalidData = await createSignedData({ timestamp: (Math.floor(Date.now() / 1000) + 60 * 60 * 2).toString() });
128128

129-
const result = await batchInsertData(undefined, batchData);
129+
const result = await batchInsertData(undefined, [invalidData], invalidData.airnode);
130130

131131
expect(result).toStrictEqual({
132132
body: JSON.stringify({
133133
message: 'Request timestamp is too far in the future',
134-
context: { signedData: batchData[0] },
134+
context: { signedData: invalidData },
135135
}),
136136
headers: {
137137
'access-control-allow-methods': '*',
@@ -154,7 +154,7 @@ describe(batchInsertData.name, () => {
154154
await createSignedData({ airnodeWallet: airnodeWallet2 }),
155155
];
156156

157-
const result = await batchInsertData(undefined, batchData);
157+
const result = await batchInsertData(undefined, batchData, airnodeWallet1.address);
158158

159159
expect(result).toStrictEqual({
160160
body: JSON.stringify({
@@ -175,11 +175,42 @@ describe(batchInsertData.name, () => {
175175
});
176176
});
177177

178+
it('rejects a batch when the path parameter conflicts with the Airnode address populated in the signed data', async () => {
179+
const airnodeWallet1 = ethers.Wallet.fromMnemonic(
180+
'echo dose empower ensure purchase enjoy once hotel slender loop repair desk'
181+
);
182+
const airnodeWallet2 = ethers.Wallet.fromMnemonic(
183+
'clay drift protect wise love frost tourist eyebrow glide cost comfort punch'
184+
);
185+
const batchData = [
186+
await createSignedData({ airnodeWallet: airnodeWallet2 }),
187+
await createSignedData({ airnodeWallet: airnodeWallet2 }),
188+
];
189+
190+
const result = await batchInsertData(undefined, batchData, airnodeWallet1.address);
191+
192+
expect(result).toStrictEqual({
193+
body: JSON.stringify({
194+
message: 'Airnode address in the path parameter does not match one in the signed data',
195+
context: {
196+
airnodeAddress: airnodeWallet1.address,
197+
signedData: batchData[0],
198+
},
199+
}),
200+
headers: {
201+
'access-control-allow-methods': '*',
202+
'access-control-allow-origin': '*',
203+
'content-type': 'application/json',
204+
},
205+
statusCode: 400,
206+
});
207+
});
208+
178209
it('inserts the batch if data is valid', async () => {
179210
const airnodeWallet = generateRandomWallet();
180211
const batchData = [await createSignedData({ airnodeWallet }), await createSignedData({ airnodeWallet })];
181212

182-
const result = await batchInsertData(undefined, batchData);
213+
const result = await batchInsertData(undefined, batchData, airnodeWallet.address);
183214

184215
expect(result).toStrictEqual({
185216
body: JSON.stringify({ count: 2, skipped: 0 }),
@@ -201,8 +232,9 @@ describe(batchInsertData.name, () => {
201232

202233
describe(getData.name, () => {
203234
it('drops the request if the airnode address is invalid', async () => {
204-
const batchData = [await createSignedData(), await createSignedData()];
205-
await batchInsertData(undefined, batchData);
235+
const airnodeWallet = generateRandomWallet();
236+
const batchData = [await createSignedData({ airnodeWallet }), await createSignedData({ airnodeWallet })];
237+
await batchInsertData(undefined, batchData, airnodeWallet.address);
206238

207239
const result = await getData({ authTokens: null, delaySeconds: 0, urlPath: 'path' }, undefined, '0xInvalid');
208240

@@ -220,7 +252,7 @@ describe(getData.name, () => {
220252
it('returns the live data', async () => {
221253
const airnodeWallet = generateRandomWallet();
222254
const batchData = [await createSignedData({ airnodeWallet }), await createSignedData({ airnodeWallet })];
223-
await batchInsertData(undefined, batchData);
255+
await batchInsertData(undefined, batchData, airnodeWallet.address);
224256

225257
const result = await getData(
226258
{ authTokens: null, delaySeconds: 0, urlPath: 'path' },
@@ -252,7 +284,7 @@ describe(getData.name, () => {
252284
await createSignedData({ airnodeWallet, timestamp: delayTimestamp }),
253285
await createSignedData({ airnodeWallet }),
254286
];
255-
await batchInsertData(undefined, batchData);
287+
await batchInsertData(undefined, batchData, airnodeWallet.address);
256288

257289
const result = await getData(
258290
{ authTokens: null, delaySeconds: 30, urlPath: 'path' },
@@ -281,7 +313,7 @@ describe(listAirnodeAddresses.name, () => {
281313
it('returns the list of airnode addresses', async () => {
282314
const airnodeWallet = generateRandomWallet();
283315
const batchData = [await createSignedData({ airnodeWallet }), await createSignedData({ airnodeWallet })];
284-
await batchInsertData(undefined, batchData);
316+
await batchInsertData(undefined, batchData, airnodeWallet.address);
285317

286318
const result = await listAirnodeAddresses();
287319

packages/signed-api/src/handlers.ts

+29-21
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,27 @@ import {
2222
// important for the delayed endpoint which may not be allowed to return the fresh data yet.
2323
export const batchInsertData = async (
2424
authorizationHeader: string | undefined,
25-
requestBody: unknown
25+
requestBody: unknown,
26+
airnodeAddress: string
2627
): Promise<ApiResponse> => {
28+
// Ensure that the batch of signed that comes from a whitelisted Airnode.
29+
const { endpoints, allowedAirnodes } = getConfig();
30+
if (allowedAirnodes !== '*') {
31+
// Find the allowed airnode and extract the request token.
32+
const allowedAirnode = allowedAirnodes.find((allowedAirnode) => allowedAirnode.address === airnodeAddress);
33+
const authToken = extractBearerToken(authorizationHeader);
34+
35+
// Check if the airnode is allowed and if the auth token is valid.
36+
const isAirnodeAllowed = Boolean(allowedAirnode);
37+
const isAuthTokenValid = allowedAirnode?.authTokens === null || allowedAirnode?.authTokens.includes(authToken!);
38+
if (!isAirnodeAllowed || !isAuthTokenValid) {
39+
if (isAirnodeAllowed) {
40+
logger.debug(`Invalid auth token`, { allowedAirnode, authToken });
41+
}
42+
return generateErrorResponse(403, 'Unauthorized Airnode address', { airnodeAddress });
43+
}
44+
}
45+
2746
const goValidateSchema = await go(async () => batchSignedDataSchema.parseAsync(requestBody));
2847
if (!goValidateSchema.success) {
2948
return generateErrorResponse(400, 'Invalid request, body must fit schema for batch of signed data', {
@@ -35,31 +54,20 @@ export const batchInsertData = async (
3554
const batchSignedData = goValidateSchema.data;
3655
if (isEmpty(batchSignedData)) return generateErrorResponse(400, 'No signed data to push');
3756

38-
// Check if all signed data is from the same airnode
57+
// Check if all signed data is from the same airnode.
3958
const signedDataAirnodes = new Set(batchSignedData.map((signedData) => signedData.airnode));
4059
if (signedDataAirnodes.size > 1) {
4160
return generateErrorResponse(400, 'All signed data must be from the same Airnode address', {
4261
airnodeAddresses: [...signedDataAirnodes],
4362
});
4463
}
45-
const airnodeAddress = batchSignedData[0]!.airnode;
4664

47-
// Ensure that the batch of signed that comes from a whitelisted Airnode.
48-
const { endpoints, allowedAirnodes } = getConfig();
49-
if (allowedAirnodes !== '*') {
50-
// Find the allowed airnode and extract the request token.
51-
const allowedAirnode = allowedAirnodes.find((allowedAirnode) => allowedAirnode.address === airnodeAddress);
52-
const authToken = extractBearerToken(authorizationHeader);
53-
54-
// Check if the airnode is allowed and if the auth token is valid.
55-
const isAirnodeAllowed = Boolean(allowedAirnode);
56-
const isAuthTokenValid = allowedAirnode?.authTokens === null || allowedAirnode?.authTokens.includes(authToken!);
57-
if (!isAirnodeAllowed || !isAuthTokenValid) {
58-
if (isAirnodeAllowed) {
59-
logger.debug(`Invalid auth token`, { allowedAirnode, authToken });
60-
}
61-
return generateErrorResponse(403, 'Unauthorized Airnode address', { airnodeAddress });
62-
}
65+
// Check if the path parameter matches the airnode address in the signed data.
66+
if (airnodeAddress !== batchSignedData[0]!.airnode) {
67+
return generateErrorResponse(400, 'Airnode address in the path parameter does not match one in the signed data', {
68+
airnodeAddress,
69+
signedData: batchSignedData[0],
70+
});
6371
}
6472

6573
// Check whether any duplications exist
@@ -111,15 +119,15 @@ export const batchInsertData = async (
111119
newSignedData.push(signedData);
112120
}
113121

114-
// Write batch of validated data to the database
122+
// Write batch of validated data to the database.
115123
const goBatchWriteDb = await go(async () => putAll(newSignedData));
116124
if (!goBatchWriteDb.success) {
117125
return generateErrorResponse(500, 'Unable to send batch of signed data to database', {
118126
detail: goBatchWriteDb.error.message,
119127
});
120128
}
121129

122-
// Prune the cache with the data that is too old (no endpoint will ever return it)
130+
// Prune the cache with the data that is too old (no endpoint will ever return it).
123131
const maxDelay = endpoints.reduce((acc, endpoint) => Math.max(acc, endpoint.delaySeconds), 0);
124132
const maxIgnoreAfterTimestamp = Math.floor(Date.now() / 1000 - maxDelay);
125133
const goPruneCache = await go(async () => prune(newSignedData, maxIgnoreAfterTimestamp));

packages/signed-api/src/server.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ export const startServer = (config: Config, port: number) => {
1616
// The default limit is 100kb, which is not enough for the signed API because some payloads can be quite large.
1717
app.use(express.json({ limit: '10mb' }));
1818

19-
app.post('/', async (req, res, next) => {
19+
app.post('/:airnodeAddress', async (req, res, next) => {
2020
const goRequest = await go(async () => {
21-
logger.info('Received request "POST /".');
22-
logger.debug('Request details.', { body: req.body });
21+
logger.info('Received request "POST /:airnodeAddress".');
22+
logger.debug('Request details.', { body: req.body, params: req.params });
2323

24-
const result = await batchInsertData(req.headers.authorization, req.body);
24+
const result = await batchInsertData(req.headers.authorization, req.body, req.params.airnodeAddress);
2525
res.status(result.statusCode).header(result.headers).send(result.body);
2626

2727
logger.debug('Responded to request "POST /".', result);

0 commit comments

Comments
 (0)