Skip to content

Commit 77da8a4

Browse files
authored
feat: Add security headers in response (#779)
* feat: Add security headers in response * change order * update cross-spawn package * fix import * wip * fix cors, remove basic auth * use const * small updates
1 parent d25788e commit 77da8a4

File tree

25 files changed

+235
-668
lines changed

25 files changed

+235
-668
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
"@cloud-cryptographic-wallet/cloud-kms-signer": "^0.1.2",
3333
"@cloud-cryptographic-wallet/signer": "^0.0.5",
3434
"@ethersproject/json-wallets": "^5.7.0",
35-
"@fastify/basic-auth": "^5.1.1",
3635
"@fastify/swagger": "^8.9.0",
3736
"@fastify/type-provider-typebox": "^3.2.0",
3837
"@fastify/websocket": "^8.2.0",
@@ -67,7 +66,6 @@
6766
"pg": "^8.11.3",
6867
"prisma": "^5.14.0",
6968
"prom-client": "^15.1.3",
70-
"prool": "^0.0.16",
7169
"superjson": "^2.2.1",
7270
"thirdweb": "5.61.3",
7371
"uuid": "^9.0.1",
@@ -91,6 +89,7 @@
9189
"eslint-config-prettier": "^8.7.0",
9290
"openapi-typescript-codegen": "^0.25.0",
9391
"prettier": "^2.8.7",
92+
"prool": "^0.0.16",
9493
"typescript": "^5.1.3",
9594
"vitest": "^2.0.3"
9695
},
@@ -112,6 +111,7 @@
112111
"elliptic": ">=6.6.0",
113112
"micromatch": ">=4.0.8",
114113
"secp256k1": ">=4.0.4",
115-
"ws": ">=8.17.1"
114+
"ws": ">=8.17.1",
115+
"cross-spawn": ">=7.0.6"
116116
}
117117
}

src/server/index.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fastify, { type FastifyInstance } from "fastify";
33
import * as fs from "node:fs";
44
import path from "node:path";
55
import { URL } from "node:url";
6+
import { getConfig } from "../utils/cache/getConfig";
67
import { clearCacheCron } from "../utils/cron/clearCacheCron";
78
import { env } from "../utils/env";
89
import { logger } from "../utils/logger";
@@ -15,9 +16,10 @@ import { withCors } from "./middleware/cors";
1516
import { withEnforceEngineMode } from "./middleware/engineMode";
1617
import { withErrorHandler } from "./middleware/error";
1718
import { withRequestLogs } from "./middleware/logs";
18-
import { withOpenApi } from "./middleware/open-api";
19+
import { withOpenApi } from "./middleware/openApi";
1920
import { withPrometheus } from "./middleware/prometheus";
2021
import { withRateLimit } from "./middleware/rateLimit";
22+
import { withSecurityHeaders } from "./middleware/securityHeaders";
2123
import { withWebSocket } from "./middleware/websocket";
2224
import { withRoutes } from "./routes";
2325
import { writeOpenApiToFile } from "./utils/openapi";
@@ -69,19 +71,23 @@ export const initServer = async () => {
6971
...(env.ENABLE_HTTPS ? httpsObject : {}),
7072
}).withTypeProvider<TypeBoxTypeProvider>();
7173

72-
server.decorateRequest("corsPreflightEnabled", false);
74+
const config = await getConfig();
7375

74-
await withCors(server);
75-
await withRequestLogs(server);
76-
await withPrometheus(server);
77-
await withErrorHandler(server);
78-
await withEnforceEngineMode(server);
79-
await withRateLimit(server);
76+
// Configure middleware
77+
withErrorHandler(server);
78+
withRequestLogs(server);
79+
withSecurityHeaders(server);
80+
withCors(server, config);
81+
withRateLimit(server);
82+
withEnforceEngineMode(server);
83+
withServerUsageReporting(server);
84+
withPrometheus(server);
85+
86+
// Register routes
8087
await withWebSocket(server);
8188
await withAuth(server);
8289
await withOpenApi(server);
8390
await withRoutes(server);
84-
await withServerUsageReporting(server);
8591
await withAdminRoutes(server);
8692

8793
await server.ready();

src/server/middleware/adminRoutes.ts

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { createBullBoard } from "@bull-board/api";
22
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
33
import { FastifyAdapter } from "@bull-board/fastify";
4-
import fastifyBasicAuth from "@fastify/basic-auth";
54
import type { Queue } from "bullmq";
6-
import { timingSafeEqual } from "crypto";
75
import type { FastifyInstance } from "fastify";
86
import { StatusCodes } from "http-status-codes";
7+
import { timingSafeEqual } from "node:crypto";
98
import { env } from "../../utils/env";
109
import { CancelRecycledNoncesQueue } from "../../worker/queues/cancelRecycledNoncesQueue";
1110
import { MigratePostgresTransactionsQueue } from "../../worker/queues/migratePostgresTransactionsQueue";
@@ -19,7 +18,9 @@ import { SendTransactionQueue } from "../../worker/queues/sendTransactionQueue";
1918
import { SendWebhookQueue } from "../../worker/queues/sendWebhookQueue";
2019

2120
export const ADMIN_QUEUES_BASEPATH = "/admin/queues";
21+
const ADMIN_ROUTES_USERNAME = "admin";
2222
const ADMIN_ROUTES_PASSWORD = env.THIRDWEB_API_SECRET_KEY;
23+
2324
// Add queues to monitor here.
2425
const QUEUES: Queue[] = [
2526
SendWebhookQueue.q,
@@ -35,57 +36,59 @@ const QUEUES: Queue[] = [
3536
];
3637

3738
export const withAdminRoutes = async (fastify: FastifyInstance) => {
38-
// Configure basic auth.
39-
await fastify.register(fastifyBasicAuth, {
40-
validate: (username, password, req, reply, done) => {
41-
if (assertAdminBasicAuth(username, password)) {
42-
done();
43-
return;
44-
}
45-
done(new Error("Unauthorized"));
46-
},
47-
authenticate: true,
48-
});
49-
50-
// Set up routes after Fastify is set up.
5139
fastify.after(async () => {
52-
// Register bullboard UI.
40+
// Create a new route for Bullboard routes.
5341
const serverAdapter = new FastifyAdapter();
5442
serverAdapter.setBasePath(ADMIN_QUEUES_BASEPATH);
5543

5644
createBullBoard({
5745
queues: QUEUES.map((q) => new BullMQAdapter(q)),
5846
serverAdapter,
5947
});
48+
6049
await fastify.register(serverAdapter.registerPlugin(), {
6150
basePath: ADMIN_QUEUES_BASEPATH,
6251
prefix: ADMIN_QUEUES_BASEPATH,
6352
});
6453

65-
// Apply basic auth only to admin routes.
66-
fastify.addHook("onRequest", (req, reply, done) => {
54+
fastify.addHook("onRequest", async (req, reply) => {
6755
if (req.url.startsWith(ADMIN_QUEUES_BASEPATH)) {
68-
fastify.basicAuth(req, reply, (error) => {
69-
if (error) {
70-
reply
71-
.status(StatusCodes.UNAUTHORIZED)
72-
.send({ error: "Unauthorized" });
73-
return done(error);
74-
}
75-
});
56+
const authHeader = req.headers.authorization;
57+
58+
if (!authHeader || !authHeader.startsWith("Basic ")) {
59+
reply
60+
.status(StatusCodes.UNAUTHORIZED)
61+
.header("WWW-Authenticate", 'Basic realm="Admin Routes"')
62+
.send({ error: "Unauthorized" });
63+
return;
64+
}
65+
66+
// Parse the basic auth credentials (`Basic <base64 of username:password>`).
67+
const base64Credentials = authHeader.split(" ")[1];
68+
const credentials = Buffer.from(base64Credentials, "base64").toString(
69+
"utf8",
70+
);
71+
const [username, password] = credentials.split(":");
72+
73+
if (!assertAdminBasicAuth(username, password)) {
74+
reply
75+
.status(StatusCodes.UNAUTHORIZED)
76+
.header("WWW-Authenticate", 'Basic realm="Admin Routes"')
77+
.send({ error: "Unauthorized" });
78+
return;
79+
}
7680
}
77-
done();
7881
});
7982
});
8083
};
8184

8285
const assertAdminBasicAuth = (username: string, password: string) => {
83-
if (username === "admin") {
86+
if (username === ADMIN_ROUTES_USERNAME) {
8487
try {
8588
const buf1 = Buffer.from(password.padEnd(100));
8689
const buf2 = Buffer.from(ADMIN_ROUTES_PASSWORD.padEnd(100));
8790
return timingSafeEqual(buf1, buf2);
88-
} catch (e) {}
91+
} catch {}
8992
}
9093
return false;
9194
};

src/server/middleware/auth.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { logger } from "../../utils/logger";
2525
import { sendWebhookRequest } from "../../utils/webhook";
2626
import { Permission } from "../schemas/auth";
2727
import { ADMIN_QUEUES_BASEPATH } from "./adminRoutes";
28-
import { OPENAPI_ROUTES } from "./open-api";
28+
import { OPENAPI_ROUTES } from "./openApi";
2929

3030
export type TAuthData = never;
3131
export type TAuthSession = { permissions: string };
@@ -43,7 +43,7 @@ declare module "fastify" {
4343
}
4444
}
4545

46-
export const withAuth = async (server: FastifyInstance) => {
46+
export async function withAuth(server: FastifyInstance) {
4747
const config = await getConfig();
4848

4949
// Configure the ThirdwebAuth fastify plugin
@@ -140,7 +140,7 @@ export const withAuth = async (server: FastifyInstance) => {
140140
message,
141141
});
142142
});
143-
};
143+
}
144144

145145
export const onRequest = async ({
146146
req,

src/server/middleware/cors.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { FastifyInstance } from "fastify";
2+
import type { ParsedConfig } from "../../schema/config";
3+
import { ADMIN_QUEUES_BASEPATH } from "./adminRoutes";
4+
5+
const STANDARD_METHODS = "GET,POST,DELETE,PUT,PATCH,HEAD,PUT,PATCH,POST,DELETE";
6+
const DEFAULT_ALLOWED_HEADERS = [
7+
"Authorization",
8+
"Content-Type",
9+
"ngrok-skip-browser-warning",
10+
];
11+
12+
export function withCors(server: FastifyInstance, config: ParsedConfig) {
13+
server.addHook("onRequest", async (request, reply) => {
14+
const origin = request.headers.origin;
15+
16+
// Allow backend calls (no origin header).
17+
if (!origin) {
18+
return;
19+
}
20+
21+
// Allow admin routes to be accessed from the same host.
22+
if (request.url.startsWith(ADMIN_QUEUES_BASEPATH)) {
23+
const host = request.headers.host;
24+
const originHost = new URL(origin).host;
25+
if (originHost !== host) {
26+
reply.code(403).send({ error: "Invalid origin" });
27+
return;
28+
}
29+
return;
30+
}
31+
32+
const allowedOrigins = config.accessControlAllowOrigin
33+
.split(",")
34+
.map(sanitizeOrigin);
35+
36+
// Always set `Vary: Origin` to prevent caching issues even on invalid origins.
37+
reply.header("Vary", "Origin");
38+
39+
if (isAllowedOrigin(origin, allowedOrigins)) {
40+
// Set CORS headers if valid origin.
41+
reply.header("Access-Control-Allow-Origin", origin);
42+
reply.header("Access-Control-Allow-Methods", STANDARD_METHODS);
43+
44+
// Handle preflight requests
45+
if (request.method === "OPTIONS") {
46+
const requestedHeaders =
47+
request.headers["access-control-request-headers"];
48+
reply.header(
49+
"Access-Control-Allow-Headers",
50+
requestedHeaders ?? DEFAULT_ALLOWED_HEADERS.join(","),
51+
);
52+
53+
reply.header("Cache-Control", "public, max-age=3600");
54+
reply.header("Access-Control-Max-Age", "3600");
55+
reply.code(204).send();
56+
return;
57+
}
58+
} else {
59+
reply.code(403).send({ error: "Invalid origin" });
60+
return;
61+
}
62+
});
63+
}
64+
65+
function isAllowedOrigin(origin: string, allowedOrigins: string[]) {
66+
return (
67+
allowedOrigins
68+
// Check if the origin matches any allowed origins.
69+
.some((allowed) => {
70+
if (allowed === "https://thirdweb-preview.com") {
71+
return /^https?:\/\/.*\.thirdweb-preview\.com$/.test(origin);
72+
}
73+
if (allowed === "https://thirdweb-dev.com") {
74+
return /^https?:\/\/.*\.thirdweb-dev\.com$/.test(origin);
75+
}
76+
77+
// Allow wildcards in the origin. For example "foo.example.com" matches "*.example.com"
78+
if (allowed.includes("*")) {
79+
const wildcardPattern = allowed.replace(/\*/g, ".*");
80+
const regex = new RegExp(`^${wildcardPattern}$`);
81+
return regex.test(origin);
82+
}
83+
84+
// Otherwise check for an exact match.
85+
return origin === allowed;
86+
})
87+
);
88+
}
89+
90+
function sanitizeOrigin(origin: string) {
91+
if (origin.endsWith("/")) {
92+
return origin.slice(0, -1);
93+
}
94+
return origin;
95+
}

0 commit comments

Comments
 (0)