Skip to content

Commit

Permalink
ACCESS_CONTROL_ALLOW_ORIGIN into Config (#372)
Browse files Browse the repository at this point in the history
* Add try/catch to webhook request

* Add manual webhook status

* Fix repeat webhooks

* added CORS config to DB

* dynamic loading of cors urls

* uncommented commented out data

* updated getConfiguration() to have accessControlAllowOrigin field with default values onCreate

* Updatd Implementation to now send Error Webhook

* updated input to stringp[]

---------

Co-authored-by: Adam Majmudar <[email protected]>
Co-authored-by: Adam Majmudar <[email protected]>
  • Loading branch information
3 people authored Jan 11, 2024
1 parent 57fe1f9 commit a699049
Show file tree
Hide file tree
Showing 17 changed files with 664 additions and 14 deletions.
3 changes: 0 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ ADMIN_WALLET_ADDRESS="<your-admin-wallet-address>"
# PORT="3005"
# HOST="0.0.0.0"

# Optional configuration to enable cors, defaults to allow all
# ACCESS_CONTROL_ALLOW_ORIGIN="*"

# Optional configuration to enable https usage for localhost
# ENABLE_HTTPS="false"
# HTTPS_PASSPHRASE="..."
2 changes: 2 additions & 0 deletions src/db/configuration/getConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Configuration } from "@prisma/client";
import { LocalWallet } from "@thirdweb-dev/wallets";
import { WalletType } from "../../schema/wallet";
import { mandatoryAllowedCorsUrls } from "../../server/utils/cors-urls";
import { decrypt } from "../../utils/crypto";
import { env } from "../../utils/env";
import { logger } from "../../utils/logger";
Expand Down Expand Up @@ -184,6 +185,7 @@ export const getConfiguration = async (): Promise<Config> => {
authDomain: "thirdweb.com",
authWalletEncryptedJson: await createAuthWalletEncryptedJson(),
minWalletBalance: "20000000000000000",
accessControlAllowOrigin: mandatoryAllowedCorsUrls.join(","),
},
update: {},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "configuration" ADD COLUMN "accessControlAllowOrigin" TEXT NOT NULL DEFAULT 'https://thirdweb.com,https://embed.ipfscdn.io';
1 change: 1 addition & 0 deletions src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ model Configuration {
webhookAuthBearerToken String? @map("webhookAuthBearerToken")
// Wallet balance
minWalletBalance String @default("20000000000000000") @map("minWalletBalance")
accessControlAllowOrigin String @default("https://thirdweb.com,https://embed.ipfscdn.io") @map("accessControlAllowOrigin")
@@map("configuration")
}
Expand Down
2 changes: 2 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const main = async () => {
...(env.ENABLE_HTTPS ? httpsObject : {}),
}).withTypeProvider<TypeBoxTypeProvider>();

server.decorateRequest("corsPreflightEnabled", false);

await withCors(server);
await withRequestLogs(server);
await withErrorHandler(server);
Expand Down
2 changes: 1 addition & 1 deletion src/server/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export const withAuth = async (server: FastifyInstance) => {
server.decorateRequest("user", null);

// Add auth validation middleware to check for authenticated requests
server.addHook("onRequest", async (req, res) => {
server.addHook("preHandler", async (req, res) => {
if (
req.url === "/favicon.ico" ||
req.url === "/" ||
Expand Down
327 changes: 327 additions & 0 deletions src/server/middleware/cors/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
import {
FastifyInstance,
FastifyReply,
FastifyRequest,
HookHandlerDoneFunction,
} from "fastify";
import { getConfig } from "../../../utils/cache/getConfig";
import {
addAccessControlRequestHeadersToVaryHeader,
addOriginToVaryHeader,
} from "./vary";

interface ArrayOfValueOrArray<T> extends Array<ValueOrArray<T>> {}

type OriginCallback = (
err: Error | null,
origin: ValueOrArray<OriginType>,
) => void;
type OriginType = string | boolean | RegExp;
type ValueOrArray<T> = T | ArrayOfValueOrArray<T>;
type OriginFunction = (
origin: string | undefined,
callback: OriginCallback,
) => void;

interface FastifyCorsOptions {
/**
* Configures the Access-Control-Allow-Origin CORS header.
*/
origin?: ValueOrArray<OriginType> | OriginFunction;
/**
* Configures the Access-Control-Allow-Credentials CORS header.
* Set to true to pass the header, otherwise it is omitted.
*/
credentials?: boolean;
/**
* Configures the Access-Control-Expose-Headers CORS header.
* Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range')
* or an array (ex: ['Content-Range', 'X-Content-Range']).
* If not specified, no custom headers are exposed.
*/
exposedHeaders?: string | string[];
/**
* Configures the Access-Control-Allow-Headers CORS header.
* Expects a comma-delimited string (ex: 'Content-Type,Authorization')
* or an array (ex: ['Content-Type', 'Authorization']). If not
* specified, defaults to reflecting the headers specified in the
* request's Access-Control-Request-Headers header.
*/
allowedHeaders?: string | string[];
/**
* Configures the Access-Control-Allow-Methods CORS header.
* Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: ['GET', 'PUT', 'POST']).
*/
methods?: string | string[];
/**
* Configures the Access-Control-Max-Age CORS header.
* Set to an integer to pass the header, otherwise it is omitted.
*/
maxAge?: number;
/**
* Configures the Cache-Control header for CORS preflight responses.
* Set to an integer to pass the header as `Cache-Control: max-age=${cacheControl}`,
* or set to a string to pass the header as `Cache-Control: ${cacheControl}` (fully define
* the header value), otherwise the header is omitted.
*/
cacheControl?: number | string | null;
/**
* Pass the CORS preflight response to the route handler (default: false).
*/
preflightContinue?: boolean;
/**
* Provides a status code to use for successful OPTIONS requests,
* since some legacy browsers (IE11, various SmartTVs) choke on 204.
*/
optionsSuccessStatus?: number;
/**
* Pass the CORS preflight response to the route handler (default: false).
*/
preflight?: boolean;
/**
* Enforces strict requirement of the CORS preflight request headers (Access-Control-Request-Method and Origin).
* Preflight requests without the required headers will result in 400 errors when set to `true` (default: `true`).
*/
strictPreflight?: boolean;
/**
* Hide options route from the documentation built using fastify-swagger (default: true).
*/
hideOptionsRoute?: boolean;
}

const defaultOptions = {
origin: "*",
methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
preflightContinue: false,
optionsSuccessStatus: 204,
credentials: false,
exposedHeaders: undefined,
allowedHeaders: undefined,
maxAge: undefined,
preflight: true,
strictPreflight: true,
};

export const fastifyCors = async (
fastify: FastifyInstance,
req: FastifyRequest,
reply: FastifyReply,
opts: FastifyCorsOptions,
next: HookHandlerDoneFunction,
) => {
const config = await getConfig();

const originArray = config.accessControlAllowOrigin.split(",") as string[];
opts.origin = originArray;

let hideOptionsRoute = true;
if (opts.hideOptionsRoute !== undefined) {
hideOptionsRoute = opts.hideOptionsRoute;
}
const corsOptions = normalizeCorsOptions(opts);
addCorsHeadersHandler(fastify, corsOptions, req, reply, next);

next();
};

function normalizeCorsOptions(opts: FastifyCorsOptions) {
const corsOptions = { ...defaultOptions, ...opts };
if (Array.isArray(opts.origin) && opts.origin.indexOf("*") !== -1) {
corsOptions.origin = "*";
}
if (Number.isInteger(corsOptions.cacheControl)) {
// integer numbers are formatted this way
corsOptions.cacheControl = `max-age=${corsOptions.cacheControl}`;
} else if (typeof corsOptions.cacheControl !== "string") {
// strings are applied directly and any other value is ignored
corsOptions.cacheControl = undefined;
}
return corsOptions;
}

const addCorsHeadersHandler = (
fastify: FastifyInstance,
options: FastifyCorsOptions,
req: FastifyRequest,
reply: FastifyReply,
next: HookHandlerDoneFunction,
) => {
// Always set Vary header
// https://github.com/rs/cors/issues/10
addOriginToVaryHeader(reply);

const resolveOriginOption =
typeof options.origin === "function"
? resolveOriginWrapper(fastify, options.origin)
: (_: any, cb: any) => cb(null, options.origin);

resolveOriginOption(
req,
(error: Error | null, resolvedOriginOption: boolean) => {
if (error !== null) {
return next(error);
}

// Disable CORS and preflight if false
if (resolvedOriginOption === false) {
return next();
}

// Falsy values are invalid
if (!resolvedOriginOption) {
return next(new Error("Invalid CORS origin option"));
}

addCorsHeaders(req, reply, resolvedOriginOption, options);

if (req.raw.method === "OPTIONS" && options.preflight === true) {
// Strict mode enforces the required headers for preflight
if (
options.strictPreflight === true &&
(!req.headers.origin || !req.headers["access-control-request-method"])
) {
reply
.status(400)
.type("text/plain")
.send("Invalid Preflight Request");
return;
}

req.corsPreflightEnabled = true;

addPreflightHeaders(req, reply, options);

if (!options.preflightContinue) {
// Do not call the hook callback and terminate the request
// Safari (and potentially other browsers) need content-length 0,
// for 204 or they just hang waiting for a body
reply
.code(options.optionsSuccessStatus!)
.header("Content-Length", "0")
.send();
return;
}
}

return next();
},
);
};

const addCorsHeaders = (
req: FastifyRequest,
reply: FastifyReply,
originOption: any,
corsOptions: FastifyCorsOptions,
) => {
const origin = getAccessControlAllowOriginHeader(
req.headers.origin!,
originOption,
);

// In the case of origin not allowed the header is not
// written in the response.
// https://github.com/fastify/fastify-cors/issues/127
if (origin) {
reply.header("Access-Control-Allow-Origin", origin);
}

if (corsOptions.credentials) {
reply.header("Access-Control-Allow-Credentials", "true");
}

if (corsOptions.exposedHeaders !== null) {
reply.header(
"Access-Control-Expose-Headers",
Array.isArray(corsOptions.exposedHeaders)
? corsOptions.exposedHeaders.join(", ")
: corsOptions.exposedHeaders,
);
}
};

function addPreflightHeaders(
req: FastifyRequest,
reply: FastifyReply,
corsOptions: FastifyCorsOptions,
) {
reply.header(
"Access-Control-Allow-Methods",
Array.isArray(corsOptions.methods)
? corsOptions.methods.join(", ")
: corsOptions.methods,
);

if (!corsOptions.allowedHeaders) {
addAccessControlRequestHeadersToVaryHeader(reply);
const reqAllowedHeaders = req.headers["access-control-request-headers"];
if (reqAllowedHeaders !== undefined) {
reply.header("Access-Control-Allow-Headers", reqAllowedHeaders);
}
} else {
reply.header(
"Access-Control-Allow-Headers",
Array.isArray(corsOptions.allowedHeaders)
? corsOptions.allowedHeaders.join(", ")
: corsOptions.allowedHeaders,
);
}

if (corsOptions.maxAge !== null) {
reply.header("Access-Control-Max-Age", String(corsOptions.maxAge));
}

if (corsOptions.cacheControl) {
reply.header("Cache-Control", corsOptions.cacheControl);
}
}

const resolveOriginWrapper = (fastify: FastifyInstance, origin: any) => {
return (req: FastifyRequest, cb: any) => {
const result = origin.call(fastify, req.headers.origin, cb);

// Allow for promises
if (result && typeof result.then === "function") {
result.then((res: any) => cb(null, res), cb);
}
};
};

const getAccessControlAllowOriginHeader = (
reqOrigin: string | undefined,
originOption: string,
) => {
if (originOption === "*") {
// allow any origin
return "*";
}

if (typeof originOption === "string") {
// fixed origin
return originOption;
}

// reflect origin
return isRequestOriginAllowed(reqOrigin, originOption) ? reqOrigin : false;
};

const isRequestOriginAllowed = (
reqOrigin: string | undefined,
allowedOrigin: string | RegExp,
) => {
if (Array.isArray(allowedOrigin)) {
for (let i = 0; i < allowedOrigin.length; ++i) {
if (isRequestOriginAllowed(reqOrigin, allowedOrigin[i])) {
return true;
}
}
return false;
} else if (typeof allowedOrigin === "string") {
return reqOrigin === allowedOrigin;
} else if (allowedOrigin instanceof RegExp && reqOrigin) {
allowedOrigin.lastIndex = 0;
return allowedOrigin.test(reqOrigin);
} else {
return !!allowedOrigin;
}
};
Loading

0 comments on commit a699049

Please sign in to comment.