@@ -56,2 +56,16 @@ /** | ||
| /* | ||
| * Default setting for `redirect` is "follow" in the fetch API. | ||
| * Not all runtimes follow the spec, so we need to ensure | ||
| * that the `redirect` property is set correctly. | ||
| */ | ||
| const expectedRedirect = init?.redirect ?? "follow"; | ||
| if (expectedRedirect !== this.redirect) { | ||
| Object.defineProperty(this, "redirect", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| value: expectedRedirect, | ||
| writable: false, | ||
| }); | ||
| } | ||
| /* | ||
| * Not all runtimes properly support the `credentials` property. | ||
@@ -58,0 +72,0 @@ * Bun's fetch implementation sets credentials to "include" by default |
+226
-72
@@ -5,2 +5,3 @@ /** | ||
| */ | ||
| /* globals Headers */ | ||
| //----------------------------------------------------------------------------- | ||
@@ -10,4 +11,5 @@ // Imports | ||
| import { stringifyRequest } from "./util.js"; | ||
| import { isCorsSimpleRequest, CorsPreflightData, assertCorsResponse, processCorsResponse, validateCorsRequest, CORS_REQUEST_METHOD, CORS_REQUEST_HEADERS, CORS_ORIGIN, createCorsPreflightError, getUnsafeHeaders, } from "./cors.js"; | ||
| import { isCorsSimpleRequest, CorsPreflightData, assertCorsResponse, processCorsResponse, validateCorsRequest, CORS_REQUEST_METHOD, CORS_REQUEST_HEADERS, CORS_ORIGIN, createCorsPreflightError, getUnsafeHeaders, createCorsError, } from "./cors.js"; | ||
| import { createCustomRequest } from "./custom-request.js"; | ||
| import { isRedirectStatus, isBodylessMethod, isBodyPreservingRedirectStatus, isRequestBodyHeader, } from "./http.js"; | ||
| //----------------------------------------------------------------------------- | ||
@@ -106,2 +108,46 @@ // Type Definitions | ||
| } | ||
| /** | ||
| * Creates an opaque filtered redirect response. | ||
| * @param {typeof Response} ResponseConstructor The Response constructor to use | ||
| * @param {string} url The URL of the response | ||
| * @returns {Response} An opaque redirect response | ||
| */ | ||
| function createOpaqueRedirectResponse(ResponseConstructor, url) { | ||
| const response = new ResponseConstructor(null, { | ||
| status: 200, // Node.js doesn't accept 0 status, so use 200 then override it | ||
| statusText: "", | ||
| headers: {}, | ||
| }); | ||
| // Define non-configurable properties to match opaque redirect response behavior | ||
| Object.defineProperties(response, { | ||
| type: { value: "opaqueredirect", configurable: false }, | ||
| url: { value: url, configurable: false }, | ||
| ok: { value: false, configurable: false }, | ||
| redirected: { value: false, configurable: false }, | ||
| body: { value: null, configurable: false }, | ||
| bodyUsed: { value: false, configurable: false }, | ||
| status: { value: 0, configurable: false }, | ||
| }); | ||
| return response; | ||
| } | ||
| /** | ||
| * Checks if a redirect needs to adjust the request method and headers. | ||
| * @param {Request} request The original request | ||
| * @param {number} status The redirect status code | ||
| * @returns {boolean} True if the redirect needs to adjust the method | ||
| */ | ||
| function redirectNeedsAdjustment(request, status) { | ||
| // For 303 redirects, change method to GET if it's not already GET or HEAD | ||
| return ((status === 303 && !isBodylessMethod(request.method)) || | ||
| // For 301/302 redirects, change method to GET if the original method was POST | ||
| ((status === 301 || status === 302) && request.method === "POST")); | ||
| } | ||
| /** | ||
| * Checks if a response is tainted (violates CORS) | ||
| * @param {Response} response The response to check | ||
| * @returns {boolean} True if the response is tainted | ||
| */ | ||
| function isTaintedResponse(response) { | ||
| return response.type.startsWith("opaque"); | ||
| } | ||
| //----------------------------------------------------------------------------- | ||
@@ -148,4 +194,77 @@ // Exports | ||
| */ | ||
| fetch; | ||
| fetch = (input, init) => this.#fetch(input, init); | ||
| /** | ||
| * @type {typeof fetch} | ||
| */ | ||
| async #fetch(input, init) { | ||
| // first check to see if the request has been aborted | ||
| const signal = init?.signal; | ||
| signal?.throwIfAborted(); | ||
| // TODO: For some reason this causes Mocha tests to fail with "multiple done" | ||
| // signal?.addEventListener("abort", () => { | ||
| // throw new Error("Fetch was aborted."); | ||
| // }); | ||
| // adjust any relative URLs | ||
| const fixedInput = typeof input === "string" && this.#baseUrl | ||
| ? new URL(input, this.#baseUrl).toString() | ||
| : input; | ||
| const request = new this.#Request(fixedInput, init); | ||
| let useCors = false; | ||
| let useCorsCredentials = false; | ||
| let preflightData; | ||
| let isSimpleRequest = false; | ||
| // if there's a base URL then we need to check for CORS | ||
| if (this.#baseUrl) { | ||
| const requestUrl = new URL(request.url); | ||
| if (isSameOrigin(requestUrl, this.#baseUrl)) { | ||
| // if we aren't explicitly blocking credentials then add them | ||
| if (request.credentials !== "omit") { | ||
| this.#attachCredentialsToRequest(request); | ||
| } | ||
| } | ||
| else { | ||
| // check for same-origin mode | ||
| if (request.mode === "same-origin") { | ||
| throw new TypeError(`Failed to fetch. Request mode is "same-origin" but the URL's origin is not same as the request origin ${this.#baseUrl.origin}`); | ||
| } | ||
| useCors = true; | ||
| isSimpleRequest = isCorsSimpleRequest(request); | ||
| const includeCredentials = request.credentials === "include"; | ||
| validateCorsRequest(request, this.#baseUrl.origin); | ||
| if (isSimpleRequest) { | ||
| if (includeCredentials) { | ||
| useCorsCredentials = true; | ||
| this.#attachCredentialsToRequest(request); | ||
| } | ||
| } | ||
| else { | ||
| preflightData = await this.#preflightFetch(request); | ||
| preflightData.validate(request, this.#baseUrl.origin); | ||
| if (includeCredentials) { | ||
| if (!preflightData.allowCredentials) { | ||
| throw createCorsPreflightError(request.url, this.#baseUrl.origin, "No 'Access-Control-Allow-Credentials' header is present on the requested resource."); | ||
| } | ||
| useCorsCredentials = true; | ||
| this.#attachCredentialsToRequest(request); | ||
| } | ||
| } | ||
| // add the origin header to the request | ||
| request.headers.append("origin", this.#baseUrl.origin); | ||
| // if the preflight response is successful, then we can make the actual request | ||
| } | ||
| } | ||
| signal?.throwIfAborted(); | ||
| const response = await this.#internalFetch(request, init?.body); | ||
| if (useCors && this.#baseUrl) { | ||
| // handle no-cors mode for any cross-origin request | ||
| if (isSimpleRequest && request.mode === "no-cors") { | ||
| return createOpaqueResponse(this.#Response); | ||
| } | ||
| processCorsResponse(response, this.#baseUrl.origin, useCorsCredentials); | ||
| } | ||
| signal?.throwIfAborted(); | ||
| // Process redirects | ||
| return this.#processRedirect(response, request, [], init?.body); | ||
| } | ||
| /** | ||
| * Map to store original fetch functions for objects | ||
@@ -178,72 +297,2 @@ * @type {WeakMap<object, Map<string, Function>>} | ||
| } | ||
| // create the function here to bind to `this` | ||
| this.fetch = async (input, init) => { | ||
| // first check to see if the request has been aborted | ||
| const signal = init?.signal; | ||
| signal?.throwIfAborted(); | ||
| // TODO: For some reason this causes Mocha tests to fail with "multiple done" | ||
| // signal?.addEventListener("abort", () => { | ||
| // throw new Error("Fetch was aborted."); | ||
| // }); | ||
| // adjust any relative URLs | ||
| const fixedInput = typeof input === "string" && this.#baseUrl | ||
| ? new URL(input, this.#baseUrl).toString() | ||
| : input; | ||
| const request = new this.#Request(fixedInput, init); | ||
| let useCors = false; | ||
| let useCorsCredentials = false; | ||
| let preflightData; | ||
| let isSimpleRequest = false; | ||
| // if there's a base URL then we need to check for CORS | ||
| if (this.#baseUrl) { | ||
| const requestUrl = new URL(request.url); | ||
| if (isSameOrigin(requestUrl, this.#baseUrl)) { | ||
| // if we aren't explicitly blocking credentials then add them | ||
| if (request.credentials !== "omit") { | ||
| this.#attachCredentialsToRequest(request); | ||
| } | ||
| } | ||
| else { | ||
| // check for same-origin mode | ||
| if (request.mode === "same-origin") { | ||
| throw new TypeError(`Failed to fetch. Request mode is "same-origin" but the URL's origin is not same as the request origin ${this.#baseUrl.origin}`); | ||
| } | ||
| useCors = true; | ||
| isSimpleRequest = isCorsSimpleRequest(request); | ||
| const includeCredentials = request.credentials === "include"; | ||
| validateCorsRequest(request, this.#baseUrl.origin); | ||
| if (isSimpleRequest) { | ||
| if (includeCredentials) { | ||
| useCorsCredentials = true; | ||
| this.#attachCredentialsToRequest(request); | ||
| } | ||
| } | ||
| else { | ||
| preflightData = await this.#preflightFetch(request); | ||
| preflightData.validate(request, this.#baseUrl.origin); | ||
| if (includeCredentials) { | ||
| if (!preflightData.allowCredentials) { | ||
| throw createCorsPreflightError(request.url, this.#baseUrl.origin, "No 'Access-Control-Allow-Credentials' header is present on the requested resource."); | ||
| } | ||
| useCorsCredentials = true; | ||
| this.#attachCredentialsToRequest(request); | ||
| } | ||
| } | ||
| // add the origin header to the request | ||
| request.headers.append("origin", this.#baseUrl.origin); | ||
| // if the preflight response is successful, then we can make the actual request | ||
| } | ||
| } | ||
| signal?.throwIfAborted(); | ||
| const response = await this.#internalFetch(request, init?.body); | ||
| if (useCors && this.#baseUrl) { | ||
| // handle no-cors mode for any cross-origin request | ||
| if (isSimpleRequest && request.mode === "no-cors") { | ||
| return createOpaqueResponse(this.#Response); | ||
| } | ||
| processCorsResponse(response, this.#baseUrl.origin, useCorsCredentials); | ||
| } | ||
| signal?.throwIfAborted(); | ||
| return response; | ||
| }; | ||
| } | ||
@@ -363,2 +412,107 @@ /** | ||
| } | ||
| /** | ||
| * Processes a redirect response according to the fetch spec | ||
| * @param {Response} response The response to check for a redirect | ||
| * @param {Request} request The original request | ||
| * @param {URL[]} urlList The list of URLs already visited in this redirect chain | ||
| * @param {any} requestBody The body of the original request | ||
| * @returns {Promise<Response>} The final response after any redirects | ||
| * @see https://fetch.spec.whatwg.org/#http-redirect-fetch | ||
| */ | ||
| async #processRedirect(response, request, urlList = [], requestBody = null) { | ||
| // Add current URL to list | ||
| urlList.push(new URL(request.url)); | ||
| const isRedirect = isRedirectStatus(response.status); | ||
| // Process response based on redirect status and mode | ||
| if (!isRedirect) { | ||
| // Not a redirect - set redirected flag if we've had previous redirects | ||
| if (urlList.length > 1) { | ||
| Object.defineProperty(response, "redirected", { value: true }); | ||
| } | ||
| return response; | ||
| } | ||
| // Handle based on redirect mode | ||
| switch (request.redirect) { | ||
| case "manual": | ||
| // Return an opaque redirect response | ||
| return createOpaqueRedirectResponse(this.#Response, request.url); | ||
| case "error": | ||
| // Just throw an error | ||
| throw new TypeError(`Redirect at ${request.url} was blocked due to redirect mode being 'error'`); | ||
| case "follow": | ||
| default: | ||
| // Continue with redirect handling | ||
| break; | ||
| } | ||
| // Get and validate the redirect location | ||
| const location = response.headers.get("Location"); | ||
| if (!location) { | ||
| throw new TypeError(`Redirect at ${request.url} has no Location header`); | ||
| } | ||
| // Construct the new URL | ||
| let redirectUrl; | ||
| try { | ||
| redirectUrl = new URL(location, request.url); | ||
| } | ||
| catch { | ||
| throw new TypeError(`Invalid redirect URL: ${location}`); | ||
| } | ||
| // Check for redirect loops | ||
| if (urlList.some(url => url.href === redirectUrl.href)) { | ||
| throw new TypeError(`Redirect loop detected for ${redirectUrl.href}`); | ||
| } | ||
| // Check redirect limit | ||
| if (urlList.length >= 20) { | ||
| throw new TypeError("Too many redirects (maximum is 20)"); | ||
| } | ||
| let method = request.method; | ||
| const headers = new Headers(request.headers); | ||
| // If this is a redirect that changes the method, adjust accordingly | ||
| if (redirectNeedsAdjustment(request, response.status)) { | ||
| method = "GET"; | ||
| for (const header of headers.keys()) { | ||
| // Remove headers that should not be sent with GET requests | ||
| if (isRequestBodyHeader(header)) { | ||
| headers.delete(header); | ||
| } | ||
| } | ||
| } | ||
| // Create a new request for the redirect | ||
| const init = { | ||
| method, | ||
| headers, | ||
| mode: request.mode, | ||
| credentials: request.credentials, | ||
| redirect: request.redirect, | ||
| referrer: request.referrer, | ||
| referrerPolicy: request.referrerPolicy, | ||
| signal: request.signal, | ||
| keepalive: request.keepalive, | ||
| body: null, | ||
| }; | ||
| // Determine if we should preserve the body (307/308 redirects) | ||
| const preserveBodyStatus = isBodyPreservingRedirectStatus(response.status); | ||
| if (preserveBodyStatus && | ||
| requestBody !== null && | ||
| !isBodylessMethod(method)) { | ||
| init.body = requestBody; | ||
| } | ||
| // Check if this is a cross-origin redirect | ||
| const currentOrigin = new URL(request.url).origin; | ||
| const isCrossOrigin = this.#baseUrl && redirectUrl.origin !== currentOrigin; | ||
| if (isCrossOrigin) { | ||
| // For non-same-origin redirect, remove authorization header | ||
| init.headers.delete("authorization"); | ||
| // For cross-origin redirect with credentials, check for CORS issues | ||
| if (request.credentials === "include" && | ||
| !isTaintedResponse(response)) { | ||
| throw createCorsError(redirectUrl.href, this.#baseUrl?.origin || "", "Cross-origin redirect with credentials is not allowed"); | ||
| } | ||
| } | ||
| // Make the new request | ||
| const redirectRequest = new this.#Request(redirectUrl.href, init); | ||
| const redirectResponse = await this.#internalFetch(redirectRequest, init.body); | ||
| // Process further redirects recursively | ||
| return this.#processRedirect(redirectResponse, redirectRequest, urlList, init.body); | ||
| } | ||
| // #region: Testing Helpers | ||
@@ -365,0 +519,0 @@ /** |
+30
-0
| /** | ||
| * Checks if a status code represents a redirect | ||
| * @param {number} status The HTTP status code | ||
| * @returns {boolean} True if the status code is a redirect status | ||
| */ | ||
| export function isRedirectStatus(status: number): boolean; | ||
| /** | ||
| * Checks if a status code is a redirect that changes the method to GET | ||
| * @param {number} status The HTTP status code | ||
| * @returns {boolean} True if the status code is a method-changing redirect status | ||
| */ | ||
| export function isMethodChangingRedirectStatus(status: number): boolean; | ||
| /** | ||
| * Checks if a status code is a body-preserving redirect | ||
| * @param {number} status The HTTP status code | ||
| * @returns {boolean} True if the status code is a body-preserving redirect status | ||
| */ | ||
| export function isBodyPreservingRedirectStatus(status: number): boolean; | ||
| /** | ||
| * Checks if a method is considered a safe method (GET or HEAD) | ||
| * @param {string} method The HTTP method | ||
| * @returns {boolean} True if the method is a safe method | ||
| */ | ||
| export function isBodylessMethod(method: string): boolean; | ||
| /** | ||
| * Checks if a header is a request body header | ||
| * @param {string} header The HTTP header name | ||
| * @returns {boolean} True if the header is a request body header | ||
| */ | ||
| export function isRequestBodyHeader(header: string): boolean; | ||
| /** | ||
| * @fileoverview HTTP status codes and text | ||
@@ -3,0 +33,0 @@ * @author Nicholas C. Zakas |
+51
-0
@@ -83,1 +83,52 @@ /** | ||
| ]; | ||
| const redirectStatuses = new Set([301, 302, 303, 307, 308]); | ||
| const methodChangingRedirectStatuses = new Set([301, 302, 303]); | ||
| const bodyPreservingRedirectStatuses = new Set([307, 308]); | ||
| // methods that don't need request bodies | ||
| const bodylessMethods = new Set(["GET", "HEAD"]); | ||
| const requestBodyHeaders = new Set([ | ||
| "content-encoding", | ||
| "content-language", | ||
| "content-location", | ||
| "content-type", | ||
| ]); | ||
| /** | ||
| * Checks if a status code represents a redirect | ||
| * @param {number} status The HTTP status code | ||
| * @returns {boolean} True if the status code is a redirect status | ||
| */ | ||
| export function isRedirectStatus(status) { | ||
| return redirectStatuses.has(status); | ||
| } | ||
| /** | ||
| * Checks if a status code is a redirect that changes the method to GET | ||
| * @param {number} status The HTTP status code | ||
| * @returns {boolean} True if the status code is a method-changing redirect status | ||
| */ | ||
| export function isMethodChangingRedirectStatus(status) { | ||
| return methodChangingRedirectStatuses.has(status); | ||
| } | ||
| /** | ||
| * Checks if a status code is a body-preserving redirect | ||
| * @param {number} status The HTTP status code | ||
| * @returns {boolean} True if the status code is a body-preserving redirect status | ||
| */ | ||
| export function isBodyPreservingRedirectStatus(status) { | ||
| return bodyPreservingRedirectStatuses.has(status); | ||
| } | ||
| /** | ||
| * Checks if a method is considered a safe method (GET or HEAD) | ||
| * @param {string} method The HTTP method | ||
| * @returns {boolean} True if the method is a safe method | ||
| */ | ||
| export function isBodylessMethod(method) { | ||
| return bodylessMethods.has(method); | ||
| } | ||
| /** | ||
| * Checks if a header is a request body header | ||
| * @param {string} header The HTTP header name | ||
| * @returns {boolean} True if the header is a request body header | ||
| */ | ||
| export function isRequestBodyHeader(header) { | ||
| return requestBodyHeaders.has(header); | ||
| } |
+1
-1
| { | ||
| "name": "mentoss", | ||
| "version": "0.9.2", | ||
| "version": "0.10.0", | ||
| "description": "A utility to mock fetch requests and responses.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
147070
7.52%3393
7.92%9
28.57%65
10.17%