Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cors)!: doesn't return access-control-allow-origin header when origin is disallowed #870

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion docs/2.utils/98.advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ router.use("/", async (event) => {

### `isCorsOriginAllowed(origin, options)`

Check if the incoming request is a CORS request.
Check if the origin is allowed.

### `isPreflightRequest(event)`

Expand Down
42 changes: 42 additions & 0 deletions src/types/utils/cors.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,53 @@
import type { HTTPMethod } from "..";

export interface H3CorsOptions {
/**
* This determines the value of the "access-control-allow-origin" response header.
* If "*", it can be used to allow all origins.
* If an array of strings or regular expressions, it can be used with origin matching.
* If a custom function, it's used to validate the origin. It takes the origin as an argument and returns `true` if allowed.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
* @default "*"
*/
origin?: "*" | "null" | (string | RegExp)[] | ((origin: string) => boolean);
/**
* This determines the value of the "access-control-allow-methods" response header of a preflight request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
* @default "*"
* @example ["GET", "HEAD", "PUT", "POST"]
*/
methods?: "*" | HTTPMethod[];
/**
* This determines the value of the "access-control-allow-headers" response header of a preflight request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
* @default "*"
*/
allowHeaders?: "*" | string[];
/**
* This determines the value of the "access-control-expose-headers" response header.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
* @default "*"
*/
exposeHeaders?: "*" | string[];
/**
* This determines the value of the "access-control-allow-credentials" response header.
* When request with credentials, the options that `origin`, `methods`, `exposeHeaders` and `allowHeaders` should not be set "*".
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
* @see https://fetch.spec.whatwg.org/#cors-protocol-and-credentials
* @default false
*/
credentials?: boolean;
/**
* This determines the value of the "access-control-max-age" response header of a preflight request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
* @default false
*/
maxAge?: string | false;
preflight?: {
statusCode?: number;
Expand Down
35 changes: 20 additions & 15 deletions src/utils/internal/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,26 @@ export function resolveCorsOptions(
}

/**
* Check if the incoming request is a CORS request.
* Check if the origin is allowed.
*/
export function isCorsOriginAllowed(
origin: string | undefined,
origin: string | null | undefined,
options: H3CorsOptions,
): boolean {
const { origin: originOption } = options;

if (
!origin ||
!originOption ||
originOption === "*" ||
originOption === "null"
) {
if (!origin) {
return false;
}

if (!originOption || originOption === "*") {
return true;
}

if (typeof originOption === "function") {
return originOption(origin);
}

if (Array.isArray(originOption)) {
return originOption.some((_origin) => {
if (_origin instanceof RegExp) {
Expand All @@ -66,7 +69,7 @@ export function isCorsOriginAllowed(
});
}

return originOption(origin);
return originOption === origin;
}

/**
Expand All @@ -79,17 +82,19 @@ export function createOriginHeaders(
const { origin: originOption } = options;
const origin = event.request.headers.get("origin");

if (!origin || !originOption || originOption === "*") {
if (!originOption || originOption === "*") {
return { "access-control-allow-origin": "*" };
}

if (typeof originOption === "string") {
return { "access-control-allow-origin": originOption, vary: "origin" };
if (originOption === "null") {
return { "access-control-allow-origin": "null", vary: "origin" };
}

return isCorsOriginAllowed(origin, options)
? { "access-control-allow-origin": origin, vary: "origin" }
: {};
if (isCorsOriginAllowed(origin, options)) {
return { "access-control-allow-origin": origin!, vary: "origin" };
}

return {};
}

/**
Expand Down
62 changes: 44 additions & 18 deletions test/unit/cors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ describe("cors (unit)", () => {
});

describe("isCorsOriginAllowed", () => {
it("returns `true` if `origin` header is not defined", () => {
it("returns `false` if `origin` header is not defined", () => {
const origin = undefined;
const options: H3CorsOptions = {};

expect(isCorsOriginAllowed(origin, options)).toEqual(true);
expect(isCorsOriginAllowed(origin, options)).toEqual(false);
});

it("returns `true` if `origin` option is not defined", () => {
Expand All @@ -129,13 +129,13 @@ describe("cors (unit)", () => {
expect(isCorsOriginAllowed(origin, options)).toEqual(true);
});

it('returns `true` if `origin` option is `"null"`', () => {
it('returns `false` if `origin` option is `"null"`', () => {
const origin = "https://example.com";
const options: H3CorsOptions = {
origin: "null",
};

expect(isCorsOriginAllowed(origin, options)).toEqual(true);
expect(isCorsOriginAllowed(origin, options)).toEqual(false);
});

it("can detect allowed origin (string)", () => {
Expand Down Expand Up @@ -180,33 +180,35 @@ describe("cors (unit)", () => {

describe("createOriginHeaders", () => {
it('returns an object whose `access-control-allow-origin` is `"*"` if `origin` option is not defined, or `"*"`', () => {
const eventMock = mockEvent("/", {
const hasOriginEventMock = mockEvent("/", {
method: "OPTIONS",
headers: {
origin: "https://example.com",
},
});
const options1: H3CorsOptions = {};
const options2: H3CorsOptions = {
const noOriginEventMock = mockEvent("/", {
method: "OPTIONS",
headers: {},
});
const defaultOptions: H3CorsOptions = {};
const originWildcardOptions: H3CorsOptions = {
origin: "*",
};

expect(createOriginHeaders(eventMock, options1)).toEqual({
expect(createOriginHeaders(hasOriginEventMock, defaultOptions)).toEqual({
"access-control-allow-origin": "*",
});
expect(createOriginHeaders(eventMock, options2)).toEqual({
expect(
createOriginHeaders(hasOriginEventMock, originWildcardOptions),
).toEqual({
"access-control-allow-origin": "*",
});
});

it('returns an object whose `access-control-allow-origin` is `"*"` if `origin` header is not defined', () => {
const eventMock = mockEvent("/", {
method: "OPTIONS",
headers: {},
expect(createOriginHeaders(noOriginEventMock, defaultOptions)).toEqual({
"access-control-allow-origin": "*",
});
const options: H3CorsOptions = {};

expect(createOriginHeaders(eventMock, options)).toEqual({
expect(
createOriginHeaders(noOriginEventMock, originWildcardOptions),
).toEqual({
"access-control-allow-origin": "*",
});
});
Expand Down Expand Up @@ -235,6 +237,12 @@ describe("cors (unit)", () => {
origin: "http://example.com",
},
});
const noMatchEventMock = mockEvent("/", {
method: "OPTIONS",
headers: {
origin: "http://example.test",
},
});
const options1: H3CorsOptions = {
origin: ["http://example.com"],
};
Expand All @@ -246,10 +254,12 @@ describe("cors (unit)", () => {
"access-control-allow-origin": "http://example.com",
vary: "origin",
});
expect(createOriginHeaders(noMatchEventMock, options1)).toEqual({});
expect(createOriginHeaders(eventMock, options2)).toEqual({
"access-control-allow-origin": "http://example.com",
vary: "origin",
});
expect(createOriginHeaders(noMatchEventMock, options2)).toEqual({});
});

it("returns an empty object if `origin` option is one that is not allowed", () => {
Expand All @@ -269,6 +279,22 @@ describe("cors (unit)", () => {
expect(createOriginHeaders(eventMock, options1)).toEqual({});
expect(createOriginHeaders(eventMock, options2)).toEqual({});
});

it("returns an empty object if `origin` option is not wildcard and `origin` header is not defined", () => {
const eventMock = mockEvent("/", {
method: "OPTIONS",
headers: {},
});
const options1: H3CorsOptions = {
origin: ["http://example.com"],
};
const options2: H3CorsOptions = {
origin: () => false,
};

expect(createOriginHeaders(eventMock, options1)).toEqual({});
expect(createOriginHeaders(eventMock, options2)).toEqual({});
});
});

describe("createMethodsHeaders", () => {
Expand Down