Skip to content

Commit d394e75

Browse files
authored
feat: Implement 'no-cors' mode for fetch (#62)
1 parent 7102d07 commit d394e75

5 files changed

+260
-4
lines changed

src/cors.js

+109
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ const simpleRequestContentTypes = new Set([
7878
"text/plain",
7979
]);
8080

81+
const noCorsSafeHeaders = new Set([
82+
"accept",
83+
"accept-language",
84+
"content-language",
85+
"content-type",
86+
]);
87+
8188
// the methods that are forbidden to be used with CORS
8289
export const forbiddenMethods = new Set(["CONNECT", "TRACE", "TRACK"]);
8390

@@ -168,6 +175,74 @@ function isSimpleRangeHeader(range) {
168175
return firstIsNumber && secondIsNumber;
169176
}
170177

178+
/**
179+
* Checks if a string contains any CORS-unsafe request-header bytes.
180+
* @param {string} str The string to check.
181+
* @returns {boolean} `true` if the string contains CORS-unsafe bytes, `false` otherwise.
182+
* @see https://fetch.spec.whatwg.org/#cors-unsafe-request-header-byte
183+
*/
184+
function containsCorsUnsafeRequestHeaderByte(str) {
185+
186+
// eslint-disable-next-line no-control-regex
187+
const unsafeBytePattern = /[\x00-\x08\x0A-\x1F\x22\x28\x29\x3A\x3C\x3E\x3F\x40\x5B\x5C\x5D\x7B\x7D\x7F]/u;
188+
return unsafeBytePattern.test(str);
189+
}
190+
191+
/**
192+
* Checks if a request header is safe to be used with "no-cors" mode.
193+
* @param {string} name The name of the header.
194+
* @param {string} value The value of the header.
195+
* @returns {boolean} `true` if the header is safe, `false` otherwise.
196+
* @see https://fetch.spec.whatwg.org/#no-cors-safelisted-request-header-name
197+
*/
198+
function isNoCorsSafeListedRequestHeader(name, value) {
199+
200+
if (!noCorsSafeHeaders.has(name.toLowerCase())) {
201+
return false;
202+
}
203+
204+
return isCorsSafeListedRequestHeader(name, value);
205+
}
206+
207+
/**
208+
* Checks if a request header is safe to be used with CORS.
209+
* @param {string} name The name of the header.
210+
* @param {string} value The value of the header.
211+
* @returns {boolean} `true` if the header is safe, `false` otherwise.
212+
* @see https://fetch.spec.whatwg.org/#cors-safelisted-request-header
213+
*/
214+
function isCorsSafeListedRequestHeader(name, value) {
215+
216+
if (value.length > 128) {
217+
return false;
218+
}
219+
220+
const hasUnsafeByte = containsCorsUnsafeRequestHeaderByte(value);
221+
222+
switch (name.toLowerCase()) {
223+
case "accept":
224+
return !hasUnsafeByte;
225+
226+
case "accept-language":
227+
case "content-language":
228+
return !/[^0-9A-Za-z *,\-.=;]/.test(value);
229+
230+
case "content-type":
231+
if (hasUnsafeByte) {
232+
return false;
233+
}
234+
235+
return simpleRequestContentTypes.has(value.toLowerCase());
236+
237+
case "range":
238+
return isSimpleRangeHeader(value);
239+
240+
default:
241+
return false;
242+
}
243+
244+
}
245+
171246
//-----------------------------------------------------------------------------
172247
// Exports
173248
//-----------------------------------------------------------------------------
@@ -304,6 +379,40 @@ export function assertCorsCredentials(response, origin) {
304379
}
305380
}
306381

382+
/**
383+
* Asserts that a request is valid for "no-cors" mode.
384+
* @param {RequestInit} requestInit The request to check.
385+
* @returns {void}
386+
* @throws {TypeError} When the request is not valid for "no-cors" mode.
387+
*/
388+
export function assertValidNoCorsRequestInit(requestInit = {}) {
389+
const headers = requestInit.headers;
390+
const method = requestInit.method;
391+
392+
// no method means GET
393+
if (!method && !headers) {
394+
return;
395+
}
396+
397+
// otherwise check it
398+
if (method && !safeMethods.has(method)) {
399+
throw new TypeError(`Method '${method}' is not allowed in 'no-cors' mode.`);
400+
}
401+
402+
// no headers means nothing to check
403+
if (!headers) {
404+
return;
405+
}
406+
407+
const headerKeyValues = Array.from(headers instanceof Headers ? headers.entries() : Object.entries(headers));
408+
409+
for (const [header, value] of headerKeyValues) {
410+
if (!isNoCorsSafeListedRequestHeader(header, value)) {
411+
throw new TypeError(`Header '${header}' is not allowed in 'no-cors' mode.`);
412+
}
413+
}
414+
}
415+
307416
/**
308417
* Processes a CORS response to ensure it's valid and doesn't contain
309418
* any forbidden headers.

src/custom-request.js

+15
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
* @author Nicholas C. Zakas
44
*/
55

6+
//-----------------------------------------------------------------------------
7+
// Imports
8+
//-----------------------------------------------------------------------------
9+
10+
import { assertValidNoCorsRequestInit } from "./cors.js";
611

712
//-----------------------------------------------------------------------------
813
// Exports
@@ -29,6 +34,16 @@ export function createCustomRequest(RequestClass) {
2934
* @param {RequestInit} [init] The options for the fetch.
3035
*/
3136
constructor(input, init) {
37+
38+
/*
39+
* Not all runtimes validate the `mode` property correctly.
40+
* To ensure consistency across runtimes, we do the validation
41+
* and throw a consistent error.
42+
*/
43+
if (init?.mode === "no-cors") {
44+
assertValidNoCorsRequestInit(init);
45+
}
46+
3247
super(input, init);
3348

3449
// ensure id is not writable

src/fetch-mocker.js

+36-1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,32 @@ function isSameOrigin(requestUrl, baseUrl) {
105105
return requestUrl.origin === baseUrl.origin;
106106
}
107107

108+
/**
109+
* Creates an opaque response.
110+
* @param {typeof Response} ResponseConstructor The Response constructor to use
111+
* @returns {Response} An opaque response
112+
*/
113+
function createOpaqueResponse(ResponseConstructor) {
114+
const response = new ResponseConstructor(null, {
115+
status: 200, // doesn't accept a 0 status
116+
statusText: "",
117+
headers: {},
118+
});
119+
120+
// Define non-configurable properties to match opaque response behavior
121+
Object.defineProperties(response, {
122+
type: { value: "opaque", configurable: false },
123+
url: { value: "", configurable: false },
124+
ok: { value: false, configurable: false },
125+
redirected: { value: false, configurable: false },
126+
body: { value: null, configurable: false },
127+
bodyUsed: { value: false, configurable: false },
128+
status: { value: 0, configurable: false },
129+
});
130+
131+
return response;
132+
}
133+
108134
//-----------------------------------------------------------------------------
109135
// Exports
110136
//-----------------------------------------------------------------------------
@@ -216,6 +242,7 @@ export class FetchMocker {
216242
let useCors = false;
217243
let useCorsCredentials = false;
218244
let preflightData;
245+
let isSimpleRequest = false;
219246

220247
// if there's a base URL then we need to check for CORS
221248
if (this.#baseUrl) {
@@ -228,12 +255,13 @@ export class FetchMocker {
228255
}
229256
} else {
230257
useCors = true;
258+
isSimpleRequest = isCorsSimpleRequest(request);
231259
const includeCredentials =
232260
request.credentials === "include";
233261

234262
validateCorsRequest(request, this.#baseUrl.origin);
235263

236-
if (isCorsSimpleRequest(request)) {
264+
if (isSimpleRequest) {
237265
if (includeCredentials) {
238266
useCorsCredentials = true;
239267
this.#attachCredentialsToRequest(request);
@@ -268,6 +296,12 @@ export class FetchMocker {
268296
const response = await this.#internalFetch(request, init?.body);
269297

270298
if (useCors && this.#baseUrl) {
299+
300+
// handle no-cors mode for any cross-origin request
301+
if (isSimpleRequest && request.mode === "no-cors") {
302+
return createOpaqueResponse(this.#Response);
303+
}
304+
271305
processCorsResponse(
272306
response,
273307
this.#baseUrl.origin,
@@ -310,6 +344,7 @@ export class FetchMocker {
310344
* @throws {Error} When no route is matched.
311345
*/
312346
async #internalFetch(request, body = null) {
347+
313348
const allTraces = [];
314349

315350
/*

tests/custom-request.test.js

+20-3
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,29 @@ describe("createCustomRequest()", () => {
5252
assert.ok(request instanceof Request);
5353
});
5454

55-
it("should have a default mode of 'cors'", () => {
56-
const request = new CustomRequest(TEST_URL);
55+
describe("mode", () => {
56+
57+
it("should have a default mode of 'cors'", () => {
58+
const request = new CustomRequest(TEST_URL);
59+
60+
assert.strictEqual(request.mode, "cors");
61+
});
62+
63+
it("should throw an error when 'no-cors' mode is used with PUT method", () => {
64+
assert.throws(() => {
65+
new CustomRequest(TEST_URL, { method: "PUT", mode: "no-cors" });
66+
}, /Method 'PUT' is not allowed in 'no-cors' mode/);
67+
});
68+
69+
it("should throw an error when 'no-cors' mode is used with a custom header", () => {
70+
assert.throws(() => {
71+
new CustomRequest(TEST_URL, { headers: { "X-Custom-Header": "value" }, mode: "no-cors" });
72+
}, /Header 'X-Custom-Header' is not allowed in 'no-cors' mode/);
73+
});
5774

58-
assert.strictEqual(request.mode, "cors");
5975
});
6076

77+
6178
describe("clone()", () => {
6279

6380
it("should have the same class as the original request", () => {

tests/fetch-mocker.test.js

+80
Original file line numberDiff line numberDiff line change
@@ -2416,4 +2416,84 @@ describe("FetchMocker", () => {
24162416
assert.strictEqual(testObject.customFetch, originalCustomFetch);
24172417
});
24182418
});
2419+
2420+
describe("no-cors mode", () => {
2421+
it("should return an opaque response when mode is no-cors", async () => {
2422+
const server = new MockServer(API_URL);
2423+
const fetchMocker = new FetchMocker({
2424+
servers: [server],
2425+
baseUrl: ALT_BASE_URL,
2426+
});
2427+
2428+
server.get("/hello", {
2429+
status: 200,
2430+
body: "Hello world!",
2431+
});
2432+
2433+
const response = await fetchMocker.fetch(API_URL + "/hello", {
2434+
mode: "no-cors"
2435+
});
2436+
2437+
assert.strictEqual(response.type, "opaque");
2438+
assert.strictEqual(response.url, "");
2439+
assert.strictEqual(response.status, 0);
2440+
assert.strictEqual(response.statusText, "");
2441+
assert.strictEqual(response.ok, false);
2442+
assert.strictEqual(response.redirected, false);
2443+
assert.strictEqual(response.body, null);
2444+
assert.strictEqual(response.bodyUsed, false);
2445+
assert.deepStrictEqual([...response.headers.entries()], []);
2446+
});
2447+
2448+
it("should throw when using no-cors mode with non-simple request", async () => {
2449+
const server = new MockServer(API_URL);
2450+
const fetchMocker = new FetchMocker({
2451+
servers: [server],
2452+
baseUrl: ALT_BASE_URL,
2453+
});
2454+
2455+
server.put("/hello", {
2456+
status: 200,
2457+
body: "Hello world!",
2458+
});
2459+
2460+
await assert.rejects(
2461+
fetchMocker.fetch(API_URL + "/hello", {
2462+
mode: "no-cors",
2463+
method: "PUT"
2464+
}),
2465+
{
2466+
name: "TypeError",
2467+
message: "Method 'PUT' is not allowed in 'no-cors' mode."
2468+
}
2469+
);
2470+
});
2471+
2472+
it("should return an opaque response regardless of actual server response", async () => {
2473+
const server = new MockServer(API_URL);
2474+
const fetchMocker = new FetchMocker({
2475+
servers: [server],
2476+
baseUrl: ALT_BASE_URL,
2477+
});
2478+
2479+
server.get("/hello", {
2480+
status: 404,
2481+
statusText: "Not Found",
2482+
headers: {
2483+
"X-Custom": "value"
2484+
},
2485+
body: "Not found!"
2486+
});
2487+
2488+
const response = await fetchMocker.fetch(API_URL + "/hello", {
2489+
mode: "no-cors"
2490+
});
2491+
2492+
assert.strictEqual(response.type, "opaque");
2493+
assert.strictEqual(response.status, 0);
2494+
assert.strictEqual(response.statusText, "");
2495+
assert.deepStrictEqual([...response.headers.entries()], []);
2496+
assert.strictEqual(response.body, null);
2497+
});
2498+
});
24192499
});

0 commit comments

Comments
 (0)