Skip to content

Commit 60b5fd0

Browse files
authored
feat: Implement Access-Control-Expose-Headers (#31)
* feat: Implement Access-Control-Expose-Headers * Clean up formatting
1 parent 9a384b3 commit 60b5fd0

9 files changed

+312
-166
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ mocker.clearAll();
9090

9191
To work on Mentoss, you'll need:
9292

93-
* [Git](https://git-scm.com/)
94-
* [Node.js](https://nodejs.org)
93+
- [Git](https://git-scm.com/)
94+
- [Node.js](https://nodejs.org)
9595

9696
Make sure both are installed by visiting the links and following the instructions to install.
9797

docs/src/content/docs/fetch-mockers/mocking-browser-requests.mdx

+1-7
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,5 @@ describe("My API", () => {
221221
</Aside>
222222

223223
<Aside type="caution">
224-
While CORS support is mostly complete, there are two features that are not yet supported:
225-
226-
- Credentialed requests (requests that include cookies or HTTP authentication information)
227-
- The `Access-Control-Expose-Headers` header
228-
229-
Mentoss throws errors when either of these features is used to ensure it's clear that a test is not behaving as expected.
230-
224+
While CORS support is mostly complete, credentialed requests (requests that include cookies or HTTP authentication information) are not yet supported. Mentoss throws an error if you try to use a credentialed request.
231225
</Aside>

src/cors.js

+131-31
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,72 @@
88
//-----------------------------------------------------------------------------
99

1010
// the methods allowed for simple requests
11-
export const corsSafeMethods = new Set(["GET", "HEAD", "POST"]);
11+
export const safeMethods = new Set(["GET", "HEAD", "POST"]);
1212

1313
// the headers allowed for simple requests
14-
export const corsSafeHeaders = new Set([
14+
export const safeRequestHeaders = new Set([
1515
"accept",
1616
"accept-language",
1717
"content-language",
1818
"content-type",
1919
"range",
2020
]);
2121

22+
// the headers that are forbidden to be sent with requests
23+
export const forbiddenRequestHeaders = new Set([
24+
"accept-charset",
25+
"accept-encoding",
26+
"access-control-request-headers",
27+
"access-control-request-method",
28+
"connection",
29+
"content-length",
30+
"cookie",
31+
"cookie2",
32+
"date",
33+
"dnt",
34+
"expect",
35+
"host",
36+
"keep-alive",
37+
"origin",
38+
"referer",
39+
"te",
40+
"trailer",
41+
"transfer-encoding",
42+
"upgrade",
43+
"user-agent",
44+
"via",
45+
]);
46+
47+
// the headers that can be used to override the method
48+
const methodOverrideRequestHeaders = new Set([
49+
"x-http-method",
50+
"x-http-method-override",
51+
"x-method-override",
52+
]);
53+
54+
// the headers that are always allowed to be read from responses
55+
export const safeResponseHeaders = new Set([
56+
"cache-control",
57+
"content-language",
58+
"content-type",
59+
"expires",
60+
"last-modified",
61+
"pragma",
62+
]);
63+
64+
// the headers that are forbidden to be read from responses
65+
export const forbiddenResponseHeaders = new Set(["set-cookie", "set-cookie2"]);
66+
2267
// the content types allowed for simple requests
23-
const corsSimpleContentTypes = new Set([
68+
const simpleRequestContentTypes = new Set([
2469
"application/x-www-form-urlencoded",
2570
"multipart/form-data",
2671
"text/plain",
2772
]);
2873

74+
// the methods that are forbidden to be used with CORS
75+
const forbiddenMethods = new Set(["CONNECT", "TRACE", "TRACK"]);
76+
2977
export const CORS_ALLOW_ORIGIN = "Access-Control-Allow-Origin";
3078
export const CORS_ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials";
3179
export const CORS_EXPOSE_HEADERS = "Access-Control-Expose-Headers";
@@ -40,6 +88,37 @@ export const CORS_ORIGIN = "Origin";
4088
// Helpers
4189
//-----------------------------------------------------------------------------
4290

91+
/**
92+
* Checks if a method is forbidden for CORS.
93+
* @param {string} header The header to check.
94+
* @param {string} value The value to check.
95+
* @returns {boolean} `true` if the method is forbidden, `false` otherwise.
96+
* @see https://fetch.spec.whatwg.org/#forbidden-method
97+
*/
98+
function isForbiddenMethodOverride(header, value) {
99+
return (
100+
methodOverrideRequestHeaders.has(header) &&
101+
forbiddenMethods.has(value.toUpperCase())
102+
);
103+
}
104+
105+
/**
106+
* Checks if a request header is forbidden for CORS.
107+
* @param {string} header The header to check.
108+
* @param {string} value The value to check.
109+
* @returns {boolean} `true` if the header is forbidden, `false` otherwise.
110+
* @see https://fetch.spec.whatwg.org/#forbidden-header-name
111+
*/
112+
function isForbiddenRequestHeader(header, value) {
113+
// eslint-disable-line no-unused-vars
114+
return (
115+
forbiddenRequestHeaders.has(header) ||
116+
header.startsWith("proxy-") ||
117+
header.startsWith("sec-") ||
118+
isForbiddenMethodOverride(header, value)
119+
);
120+
}
121+
43122
/**
44123
* Checks if a Range header value is a simple range according to the Fetch API spec.
45124
* @see https://fetch.spec.whatwg.org/#http-headers
@@ -91,21 +170,22 @@ function isSimpleRangeHeader(range) {
91170
* Represents an error that occurs when a CORS request is blocked.
92171
*/
93172
export class CorsError extends Error {
94-
95173
/**
96174
* The name of the error.
97175
* @type {string}
98176
*/
99177
name = "CorsError";
100-
178+
101179
/**
102180
* Creates a new instance.
103181
* @param {string} requestUrl The URL of the request.
104182
* @param {string} origin The origin of the client making the request.
105183
* @param {string} message The error message.
106184
*/
107185
constructor(requestUrl, origin, message) {
108-
super(`Access to fetch at '${requestUrl}' from origin '${origin}' has been blocked by CORS policy: ${message}`);
186+
super(
187+
`Access to fetch at '${requestUrl}' from origin '${origin}' has been blocked by CORS policy: ${message}`,
188+
);
109189
}
110190
}
111191

@@ -123,7 +203,7 @@ export function assertCorsResponse(response, origin) {
123203
throw new CorsError(
124204
response.url,
125205
origin,
126-
"Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource."
206+
"Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
127207
);
128208
}
129209

@@ -134,12 +214,45 @@ export function assertCorsResponse(response, origin) {
134214
`The 'Access-Control-Allow-Origin' header has a value '${originHeader}' that is not equal to the supplied origin.`,
135215
);
136216
}
217+
}
137218

219+
/**
220+
* Processes a CORS response to ensure it's valid and doesn't contain
221+
* any forbidden headers.
222+
* @param {Response} response The response to process.
223+
* @param {string} origin The origin of the request.
224+
* @returns {Response} The processed response.
225+
*/
226+
export function processCorsResponse(response, origin) {
227+
// first check that the response is allowed
228+
assertCorsResponse(response, origin);
229+
230+
// check if the Access-Control-Expose-Headers header is present
138231
const exposedHeaders = response.headers.get(CORS_EXPOSE_HEADERS);
232+
const allowedHeaders = exposedHeaders
233+
? new Set(exposedHeaders.toLowerCase().split(", "))
234+
: new Set();
235+
236+
// next filter out any headers that aren't allowed
237+
for (const key of response.headers.keys()) {
238+
// first check if the header is always allowed
239+
if (safeResponseHeaders.has(key)) {
240+
continue;
241+
}
139242

140-
if (exposedHeaders) {
141-
throw new Error("Access-Control-Expose-Headers is not yet supported.");
243+
// next check if the header is never allowed
244+
if (forbiddenResponseHeaders.has(key)) {
245+
response.headers.delete(key);
246+
continue;
247+
}
248+
249+
// finally check if the header is allowed by the server
250+
if (!allowedHeaders.has(key)) {
251+
response.headers.delete(key);
252+
}
142253
}
254+
255+
return response;
143256
}
144257

145258
/**
@@ -149,23 +262,23 @@ export function assertCorsResponse(response, origin) {
149262
*/
150263
export function isCorsSimpleRequest(request) {
151264
// if it's not a simple method then it's not a simple request
152-
if (!corsSafeMethods.has(request.method)) {
265+
if (!safeMethods.has(request.method)) {
153266
return false;
154267
}
155268

156269
// check all headers to ensure they're allowed
157270
const headers = request.headers;
158271

159272
for (const header of headers.keys()) {
160-
if (!corsSafeHeaders.has(header)) {
273+
if (!safeRequestHeaders.has(header)) {
161274
return false;
162275
}
163276
}
164277

165278
// check the content type
166279
const contentType = headers.get("content-type");
167280

168-
if (contentType && !corsSimpleContentTypes.has(contentType)) {
281+
if (contentType && !simpleRequestContentTypes.has(contentType)) {
169282
return false;
170283
}
171284

@@ -213,12 +326,6 @@ export class CorsPreflightData {
213326
*/
214327
allowCredentials = false;
215328

216-
/**
217-
* The exposed headers for this URL.
218-
* @type {Set<string>}
219-
*/
220-
exposedHeaders = new Set();
221-
222329
/**
223330
* The maximum age for this URL.
224331
* @type {number}
@@ -248,14 +355,9 @@ export class CorsPreflightData {
248355

249356
this.allowCredentials = headers.get(CORS_ALLOW_CREDENTIALS) === "true";
250357

251-
const exposeHeaders = headers.get(CORS_EXPOSE_HEADERS);
252-
if (exposeHeaders) {
253-
this.exposedHeaders = new Set(
254-
exposeHeaders.toLowerCase().split(", "),
255-
);
256-
}
257-
258358
this.maxAge = Number(headers.get(CORS_MAX_AGE)) || Infinity;
359+
360+
// Note: Access-Control-Expose-Headers is not honored on preflight requests
259361
}
260362

261363
/**
@@ -266,12 +368,11 @@ export class CorsPreflightData {
266368
* @throws {Error} When the method is not allowed.
267369
*/
268370
#validateMethod(request, origin) {
269-
270371
const method = request.method.toUpperCase();
271-
372+
272373
if (
273374
!this.allowAllMethods &&
274-
!corsSafeMethods.has(method) &&
375+
!safeMethods.has(method) &&
275376
!this.allowedMethods.has(method)
276377
) {
277378
throw new CorsError(
@@ -290,12 +391,11 @@ export class CorsPreflightData {
290391
* @throws {Error} When the headers are not allowed.
291392
*/
292393
#validateHeaders(request, origin) {
293-
294394
const { headers } = request;
295-
395+
296396
for (const header of headers.keys()) {
297397
// simple headers are always allowed
298-
if (corsSafeHeaders.has(header)) {
398+
if (safeRequestHeaders.has(header)) {
299399
continue;
300400
}
301401

src/fetch-mocker.js

+11-7
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import {
1212
isCorsSimpleRequest,
1313
CorsPreflightData,
1414
assertCorsResponse,
15+
processCorsResponse,
1516
CORS_REQUEST_METHOD,
1617
CORS_REQUEST_HEADERS,
1718
CORS_ORIGIN,
18-
CorsError
19+
CorsError,
1920
} from "./cors.js";
2021

2122
//-----------------------------------------------------------------------------
@@ -171,7 +172,6 @@ export class FetchMocker {
171172

172173
// create the function here to bind to `this`
173174
this.fetch = async (input, init) => {
174-
175175
// first check to see if the request has been aborted
176176
const signal = init?.signal;
177177
signal?.throwIfAborted();
@@ -180,7 +180,7 @@ export class FetchMocker {
180180
// signal?.addEventListener("abort", () => {
181181
// throw new Error("Fetch was aborted.");
182182
// });
183-
183+
184184
// adjust any relative URLs
185185
const fixedInput =
186186
typeof input === "string" && this.#baseUrl
@@ -214,15 +214,15 @@ export class FetchMocker {
214214
// if the preflight response is successful, then we can make the actual request
215215
}
216216
}
217-
217+
218218
signal?.throwIfAborted();
219219

220220
const response = await this.#internalFetch(request, init?.body);
221221

222222
if (useCors && this.#baseUrl) {
223-
assertCorsResponse(response, this.#baseUrl.origin);
223+
processCorsResponse(response, this.#baseUrl.origin);
224224
}
225-
225+
226226
signal?.throwIfAborted();
227227

228228
return response;
@@ -319,7 +319,11 @@ export class FetchMocker {
319319

320320
// if the preflight response is successful, then we can make the actual request
321321
if (!preflightResponse.ok) {
322-
throw new CorsError(preflightRequest.url, this.#baseUrl.origin, "Response to preflight request doesn't pass access control check: It does not have HTTP ok status.");
322+
throw new CorsError(
323+
preflightRequest.url,
324+
this.#baseUrl.origin,
325+
"Response to preflight request doesn't pass access control check: It does not have HTTP ok status.",
326+
);
323327
}
324328

325329
assertCorsResponse(preflightResponse, this.#baseUrl.origin);

0 commit comments

Comments
 (0)