Skip to content

Commit 9061ba2

Browse files
d4mrfarhanW3
andauthored
IP Allowlist (#564)
* prisma extension is default formatter for primsa files * Add ipAllowlist to configuration schema * created allowlist migration * Add trust proxy env var and fastify param * add checkIpInAllowlist function, refactor handleAccessToken * added ipAllowlist in keypair auth * added ipAllowlist to websocketAuth * Add routes * Add IP_ALLOWLIST to features in health * chore: Update unauthorized IP address error message * Added mandatory env vars to test env * fixed webhooks * updated chain tests * removed url tests * migrated tests to vitest * Address PR Comments * Added IP Allowlist test cases * remove stray console.log * Made tests consistent * enforce node 20 --------- Co-authored-by: Farhan Khwaja <132962163+farhanW3@users.noreply.github.com>
1 parent 4c5d63a commit 9061ba2

File tree

20 files changed

+1205
-1622
lines changed

20 files changed

+1205
-1622
lines changed

.env.test

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@ THIRDWEB_API_SECRET_KEY="test"
22
POSTGRES_CONNECTION_URL="postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable"
33
ADMIN_WALLET_ADDRESS="test"
44
ENCRYPTION_PASSWORD="test"
5-
ENABLE_KEYPAIR_AUTH="true"
5+
ENABLE_KEYPAIR_AUTH="true"
6+
ENABLE_HTTPS="true"
7+
REDIS_URL="redis://127.0.0.1:6379/0"
8+
THIRDWEB_API_SECRET_KEY="my-thirdweb-secret-key"

.jest/setEnvVars.js

Lines changed: 0 additions & 1 deletion
This file was deleted.

.vscode/settings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"editor.formatOnSave": true,
88
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
99
"typescript.tsdk": "node_modules/typescript/lib",
10-
"typescript.enablePromptUseWorkspaceTsdk": true
10+
"typescript.enablePromptUseWorkspaceTsdk": true,
11+
"[prisma]": {
12+
"editor.defaultFormatter": "Prisma.prisma"
13+
}
1114
}

jest.config.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.

package.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"name": "web3-api",
33
"version": "0.1.0",
44
"license": "Apache-2.0",
5+
"engines": {
6+
"node": "20.x"
7+
},
58
"description": "thirdweb's web3-api server",
69
"main": "src/server/index.ts",
710
"author": "thirdweb engineering <eng@thirdweb.com>",
@@ -26,7 +29,8 @@
2629
"lint:fix": "eslint --fix 'src/**/*.ts'",
2730
"test:load": "npx tsx ./test/load/index.ts",
2831
"test:load:benchmark": "npx tsx ./test/load/scripts/account.ts",
29-
"test:unit": "jest"
32+
"test:unit": "vitest",
33+
"test:coverage": "vitest run --coverage"
3034
},
3135
"dependencies": {
3236
"@aws-sdk/client-kms": "^3.398.0",
@@ -79,7 +83,6 @@
7983
"@types/cookie": "^0.5.1",
8084
"@types/crypto-js": "^4.2.2",
8185
"@types/express": "^4.17.17",
82-
"@types/jest": "^29.5.11",
8386
"@types/jsonwebtoken": "^9.0.6",
8487
"@types/node": "^18.15.4",
8588
"@types/node-cron": "^3.0.8",
@@ -88,16 +91,16 @@
8891
"@types/ws": "^8.5.5",
8992
"@typescript-eslint/eslint-plugin": "^5.55.0",
9093
"@typescript-eslint/parser": "^5.55.0",
94+
"@vitest/coverage-v8": "^2.0.3",
9195
"commander": "^11.0.0",
9296
"eslint": "^9.3.0",
9397
"eslint-config-prettier": "^8.7.0",
94-
"jest": "^29.7.0",
9598
"nodemon": "^3.1.2",
9699
"openapi-typescript-codegen": "^0.25.0",
97100
"prettier": "^2.8.7",
98-
"ts-jest": "^29.1.1",
99101
"ts-node": "^10.9.1",
100-
"typescript": "^5.1.3"
102+
"typescript": "^5.1.3",
103+
"vitest": "^2.0.3"
101104
},
102105
"lint-staged": {
103106
"*.{js,ts}": "eslint --cache --fix",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "configuration" ADD COLUMN "ipAllowlist" TEXT[] DEFAULT ARRAY[]::TEXT[];

src/prisma/schema.prisma

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,26 @@ model Configuration {
3030
contractSubscriptionsRetryDelaySeconds String @default("10") @map("contractSubscriptionsRetryDelaySeconds")
3131
3232
// AWS
33-
awsAccessKeyId String? @map("awsAccessKeyId")
34-
awsSecretAccessKey String? @map("awsSecretAccessKey")
35-
awsRegion String? @map("awsRegion")
33+
awsAccessKeyId String? @map("awsAccessKeyId")
34+
awsSecretAccessKey String? @map("awsSecretAccessKey")
35+
awsRegion String? @map("awsRegion")
3636
// GCP
37-
gcpApplicationProjectId String? @map("gcpApplicationProjectId")
38-
gcpKmsLocationId String? @map("gcpKmsLocationId")
39-
gcpKmsKeyRingId String? @map("gcpKmsKeyRingId")
40-
gcpApplicationCredentialEmail String? @map("gcpApplicationCredentialEmail")
41-
gcpApplicationCredentialPrivateKey String? @map("gcpApplicationCredentialPrivateKey")
37+
gcpApplicationProjectId String? @map("gcpApplicationProjectId")
38+
gcpKmsLocationId String? @map("gcpKmsLocationId")
39+
gcpKmsKeyRingId String? @map("gcpKmsKeyRingId")
40+
gcpApplicationCredentialEmail String? @map("gcpApplicationCredentialEmail")
41+
gcpApplicationCredentialPrivateKey String? @map("gcpApplicationCredentialPrivateKey")
4242
// Auth
43-
authDomain String @default("") @map("authDomain") // TODO: Remove defaults on major
44-
authWalletEncryptedJson String @default("") @map("authWalletEncryptedJson") // TODO: Remove defaults on major
43+
authDomain String @default("") @map("authDomain") // TODO: Remove defaults on major
44+
authWalletEncryptedJson String @default("") @map("authWalletEncryptedJson") // TODO: Remove defaults on major
4545
// Webhook
46-
webhookUrl String? @map("webhookUrl")
47-
webhookAuthBearerToken String? @map("webhookAuthBearerToken")
46+
webhookUrl String? @map("webhookUrl")
47+
webhookAuthBearerToken String? @map("webhookAuthBearerToken")
4848
// Wallet balance
49-
minWalletBalance String @default("20000000000000000") @map("minWalletBalance")
50-
accessControlAllowOrigin String @default("https://thirdweb.com,https://embed.ipfscdn.io") @map("accessControlAllowOrigin")
51-
clearCacheCronSchedule String @default("*/30 * * * * *") @map("clearCacheCronSchedule")
49+
minWalletBalance String @default("20000000000000000") @map("minWalletBalance")
50+
accessControlAllowOrigin String @default("https://thirdweb.com,https://embed.ipfscdn.io") @map("accessControlAllowOrigin")
51+
ipAllowlist String[] @default([]) @map("ipAllowlist")
52+
clearCacheCronSchedule String @default("*/30 * * * * *") @map("clearCacheCronSchedule")
5253
5354
@@map("configuration")
5455
}

src/server/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const initServer = async () => {
5050
const server: FastifyInstance = fastify({
5151
connectionTimeout: SERVER_CONNECTION_TIMEOUT,
5252
disableRequestLogging: true,
53+
// env.TRUST_PROXY is used to determine if the X-Forwarded-For header should be trusted.
54+
// See: https://fastify.dev/docs/latest/Reference/Server/#trustproxy
55+
trustProxy: env.TRUST_PROXY,
5356
...(env.ENABLE_HTTPS ? httpsObject : {}),
5457
}).withTypeProvider<TypeBoxTypeProvider>();
5558

src/server/middleware/auth.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,16 @@ const handleWebsocketAuth = async (
252252
// Set as a header for `getUsers` to parse the token.
253253
req.headers.authorization = `Bearer ${jwt}`;
254254
const user = await getUser(req);
255+
256+
const isIpInAllowlist = await checkIpInAllowlist(req);
257+
if (!isIpInAllowlist) {
258+
return {
259+
isAuthed: false,
260+
error:
261+
"Unauthorized IP Address. See: https://portal.thirdweb.com/engine/features/security",
262+
};
263+
}
264+
255265
if (
256266
user?.session?.permissions === Permission.Owner ||
257267
user?.session?.permissions === Permission.Admin
@@ -309,6 +319,12 @@ const handleKeypairAuth = async (
309319
throw error;
310320
}
311321

322+
const isIpInAllowlist = await checkIpInAllowlist(req);
323+
if (!isIpInAllowlist) {
324+
error =
325+
"Unauthorized IP Address. See: https://portal.thirdweb.com/engine/features/security";
326+
throw error;
327+
}
312328
return { isAuthed: true };
313329
} catch (e) {
314330
if (e instanceof jsonwebtoken.TokenExpiredError) {
@@ -337,22 +353,39 @@ const handleAccessToken = async (
337353
req: FastifyRequest,
338354
getUser: ReturnType<typeof ThirdwebAuth<TAuthData, TAuthSession>>["getUser"],
339355
): Promise<AuthResponse> => {
356+
let token: Awaited<ReturnType<typeof getAccessToken>> = null;
357+
340358
try {
341-
const token = await getAccessToken({ jwt });
342-
if (token && token.revokedAt === null) {
343-
const user = await getUser(req);
344-
if (
345-
user?.session?.permissions === Permission.Owner ||
346-
user?.session?.permissions === Permission.Admin
347-
) {
348-
return { isAuthed: true, user };
349-
}
350-
}
359+
token = await getAccessToken({ jwt });
351360
} catch (e) {
352361
// Missing or invalid signature. This will occur if the JWT not intended for this auth pattern.
362+
return { isAuthed: false };
353363
}
354364

355-
return { isAuthed: false };
365+
if (!token || token.revokedAt) {
366+
return { isAuthed: false };
367+
}
368+
369+
const user = await getUser(req);
370+
371+
if (
372+
user?.session?.permissions !== Permission.Owner &&
373+
user?.session?.permissions !== Permission.Admin
374+
) {
375+
return { isAuthed: false };
376+
}
377+
378+
const isIpInAllowlist = await checkIpInAllowlist(req);
379+
380+
if (!isIpInAllowlist) {
381+
return {
382+
isAuthed: false,
383+
error:
384+
"Unauthorized IP Address. See: https://portal.thirdweb.com/engine/features/security",
385+
};
386+
}
387+
388+
return { isAuthed: true, user };
356389
};
357390

358391
/**
@@ -453,3 +486,21 @@ const hashRequestBody = (req: FastifyRequest): string => {
453486
.update(JSON.stringify(req.body), "utf8")
454487
.digest("hex");
455488
};
489+
490+
/**
491+
* Check if the request IP is in the allowlist.
492+
* Fetches cached config if available.
493+
* env.TRUST_PROXY is used to determine if the X-Forwarded-For header should be trusted.
494+
* @param req FastifyRequest
495+
* @returns boolean
496+
* @async
497+
*/
498+
const checkIpInAllowlist = async (req: FastifyRequest) => {
499+
const config = await getConfig();
500+
501+
if (config.ipAllowlist.length === 0) {
502+
return true;
503+
}
504+
505+
return config.ipAllowlist.includes(req.ip);
506+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Static, Type } from "@sinclair/typebox";
2+
import { FastifyInstance } from "fastify";
3+
import { StatusCodes } from "http-status-codes";
4+
import { getConfig } from "../../../../utils/cache/getConfig";
5+
import { standardResponseSchema } from "../../../schemas/sharedApiSchemas";
6+
7+
export const responseBodySchema = Type.Object({
8+
result: Type.Array(Type.String()),
9+
});
10+
11+
export async function getIpAllowlist(fastify: FastifyInstance) {
12+
fastify.route<{
13+
Reply: Static<typeof responseBodySchema>;
14+
}>({
15+
method: "GET",
16+
url: "/configuration/ip-allowlist",
17+
schema: {
18+
summary: "Get Allowed IP Addresses",
19+
description: "Get the list of allowed IP addresses",
20+
tags: ["Configuration"],
21+
operationId: "getIpAllowlist",
22+
response: {
23+
...standardResponseSchema,
24+
[StatusCodes.OK]: responseBodySchema,
25+
},
26+
},
27+
handler: async (req, res) => {
28+
const config = await getConfig(false);
29+
30+
res.status(200).send({
31+
result: config.ipAllowlist,
32+
});
33+
},
34+
});
35+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Static, Type } from "@sinclair/typebox";
2+
import { FastifyInstance } from "fastify";
3+
import { StatusCodes } from "http-status-codes";
4+
import { updateConfiguration } from "../../../../db/configuration/updateConfiguration";
5+
import { getConfig } from "../../../../utils/cache/getConfig";
6+
import { standardResponseSchema } from "../../../schemas/sharedApiSchemas";
7+
import { responseBodySchema } from "./get";
8+
9+
const requestBodySchema = Type.Object({
10+
ips: Type.Array(
11+
Type.String({
12+
minLength: 7,
13+
description: "IP address as a string",
14+
}),
15+
{
16+
description: "Array of IP addresses to allowlist",
17+
},
18+
),
19+
});
20+
21+
requestBodySchema.examples = [
22+
{
23+
ips: ["8.8.8.8", "172.217.255.255"],
24+
},
25+
];
26+
27+
export async function setIpAllowlist(fastify: FastifyInstance) {
28+
fastify.route<{
29+
Body: Static<typeof requestBodySchema>;
30+
Reply: Static<typeof responseBodySchema>;
31+
}>({
32+
method: "PUT",
33+
url: "/configuration/ip-allowlist",
34+
schema: {
35+
summary: "Set IP Allowlist",
36+
description: "Replaces the IP Allowlist array to allow calls to Engine",
37+
tags: ["Configuration"],
38+
operationId: "setIpAllowlist",
39+
body: requestBodySchema,
40+
response: {
41+
...standardResponseSchema,
42+
[StatusCodes.OK]: responseBodySchema,
43+
},
44+
},
45+
handler: async (req, res) => {
46+
const ips = req.body.ips.map((ip) => ip.trim());
47+
const dedupe = Array.from(new Set([...ips]));
48+
49+
await updateConfiguration({
50+
ipAllowlist: dedupe,
51+
});
52+
53+
// Fetch and return the updated configuration
54+
const config = await getConfig(false);
55+
res.status(200).send({
56+
result: config.ipAllowlist,
57+
});
58+
},
59+
});
60+
}

src/server/routes/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ import { checkGroupStatus } from "./transaction/group";
124124

125125
// Indexer
126126
import { setUrlsToCorsConfiguration } from "./configuration/cors/set";
127+
import { getIpAllowlist } from "./configuration/ip/get";
128+
import { setIpAllowlist } from "./configuration/ip/set";
127129
import { getContractEventLogs } from "./contract/events/getContractEventLogs";
128130
import { getEventLogs } from "./contract/events/getEventLogsByTimestamp";
129131
import { pageEventLogs } from "./contract/events/paginateEventLogs";
@@ -175,6 +177,8 @@ export const withRoutes = async (fastify: FastifyInstance) => {
175177
await fastify.register(updateCacheConfiguration);
176178
await fastify.register(getContractSubscriptionsConfiguration);
177179
await fastify.register(updateContractSubscriptionsConfiguration);
180+
await fastify.register(getIpAllowlist);
181+
await fastify.register(setIpAllowlist);
178182

179183
// Webhooks
180184
await fastify.register(getAllWebhooksData);

src/server/routes/system/health.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { isDatabaseHealthy } from "../../../db/client";
55
import { env } from "../../../utils/env";
66
import { redis } from "../../../utils/redis/redis";
77

8-
type EngineFeature = "KEYPAIR_AUTH" | "CONTRACT_SUBSCRIPTIONS";
8+
type EngineFeature = "KEYPAIR_AUTH" | "CONTRACT_SUBSCRIPTIONS" | "IP_ALLOWLIST";
99

1010
const ReplySchemaOk = Type.Object({
1111
status: Type.String(),
@@ -15,6 +15,7 @@ const ReplySchemaOk = Type.Object({
1515
Type.Union([
1616
Type.Literal("KEYPAIR_AUTH"),
1717
Type.Literal("CONTRACT_SUBSCRIPTIONS"),
18+
Type.Literal("IP_ALLOWLIST"),
1819
]),
1920
),
2021
});
@@ -61,7 +62,8 @@ export async function healthCheck(fastify: FastifyInstance) {
6162
}
6263

6364
const getFeatures = (): EngineFeature[] => {
64-
const features: EngineFeature[] = [];
65+
// IP Allowlist is always available as a feature, but added as a feature for backwards compatibility.
66+
const features: EngineFeature[] = ["IP_ALLOWLIST"];
6567

6668
if (env.ENABLE_KEYPAIR_AUTH) features.push("KEYPAIR_AUTH");
6769
// Contract Subscriptions requires Redis.

0 commit comments

Comments
 (0)