chrome-devtools-frontend
Version:
Chrome DevTools UI
173 lines (152 loc) • 7.68 kB
text/typescript
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import type * as NetworkRequest from './NetworkRequest.js';
import * as SecurityOrigin from './SecurityOrigin.js';
/**
* Specifies how a network request's response data (body and headers) can be accessed
* relative to the initiating context's security origin.
*
* DevTools features that inspect network requests (such as AI Assistance) must respect
* the browser Same-Origin Policy (SOP) and Cross-Origin Resource Sharing (CORS) rules:
* - Same-origin responses are fully readable by the initiating document.
* - Cross-origin responses with valid CORS headers (`Access-Control-Allow-Origin`) are readable.
* - Opaque cross-origin responses (such as `no-cors` fetches to third-party endpoints) are NOT
* readable by the page and must never have their response bodies or restricted headers exposed
* to unauthorized contexts (b/513821237).
*/
export const enum ResponseAccessMode {
/** The request target URL is same-origin with the initiator context. */
SAME_ORIGIN = 'SAME_ORIGIN',
/** The request is cross-origin, but explicit CORS response headers permit access by the initiator. */
CORS_ALLOWED = 'CORS_ALLOWED',
/** The request is cross-origin without CORS authorization; response body and unexposed headers are restricted. */
OPAQUE_CROSS_ORIGIN = 'OPAQUE_CROSS_ORIGIN',
}
/**
* Standard CORS-safelisted response headers that are exposed on cross-origin requests
* without requiring explicit `Access-Control-Expose-Headers`.
*
* @see https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name
*/
export const CORS_SAFELISTED_RESPONSE_HEADERS: ReadonlySet<string> = new Set([
'cache-control',
'content-language',
'content-length',
'content-type',
'expires',
'last-modified',
'pragma',
]);
/**
* Placeholder text replacing response body content when cross-origin access is forbidden.
*/
export const REDACTED_RESPONSE_BODY = '<redacted cross-origin response body>';
/**
* Returns whether a network request used credentials (cookies, authorization headers,
* or server-indicated credentials mode).
*
* @param request The network request to inspect.
* @returns True if the request included credentials or requires credentialed CORS.
* @see https://fetch.spec.whatwg.org/#credentials
*/
export function isRequestCredentialed(request: NetworkRequest.NetworkRequest): boolean {
const hasAuthHeaders = Boolean(
request.requestHeaderValue('authorization') || request.requestHeaderValue('proxy-authorization'),
);
const hasCookies = request.includedRequestCookies().length > 0 || request.responseCookies.length > 0 ||
Boolean(request.requestHeaderValue('cookie')) || Boolean(request.responseHeaderValue('set-cookie'));
const hasAllowCredentials =
request.responseHeaderValue('access-control-allow-credentials')?.trim().toLowerCase() === 'true';
return hasAuthHeaders || hasCookies || hasAllowCredentials;
}
/**
* Evaluates the response access mode for a network request relative to an initiator origin.
*
* Evaluation rules:
* 1. If `initiatorOrigin` is omitted, the origin is derived from `request.documentURL`. If
* `documentURL` is invalid or opaque, access mode defaults to `OPAQUE_CROSS_ORIGIN`.
* 2. If the initiator origin is opaque (e.g. sandboxed iframe or `data:` URL), returns `OPAQUE_CROSS_ORIGIN`.
* 3. If the initiator origin is same-origin with the request URL, returns `SAME_ORIGIN`.
* 4. If Chrome's network stack flagged a CORS error (`corsErrorStatus`), returns `OPAQUE_CROSS_ORIGIN`.
* 5. If the server provided an `Access-Control-Allow-Origin` header:
* - Wildcard `*` grants `CORS_ALLOWED` only if the request does not include credentials. Under the
* Fetch specification, wildcard `*` is invalid for credentialed requests.
* - An explicit match against the initiator origin grants `CORS_ALLOWED`.
* 6. Otherwise, returns `OPAQUE_CROSS_ORIGIN`.
*
* @param request The network request being inspected.
* @param initiatorOrigin The security origin of the initiating context (e.g. page or conversation origin).
* @returns The evaluated `ResponseAccessMode`.
*/
export function evaluateResponseAccessMode(
request: NetworkRequest.NetworkRequest,
initiatorOrigin?: SecurityOrigin.SecurityOrigin,
): ResponseAccessMode {
const effectiveInitiatorOrigin = initiatorOrigin ?? SecurityOrigin.SecurityOrigin.create(request.documentURL);
// Opaque initiator contexts (e.g. data: URLs, sandboxed iframes) are never permitted
// to inspect cross-origin response content.
if (effectiveInitiatorOrigin.isOpaque()) {
return ResponseAccessMode.OPAQUE_CROSS_ORIGIN;
}
const resourceOrigin = SecurityOrigin.SecurityOrigin.create(request.url());
if (effectiveInitiatorOrigin.isSameOriginWith(resourceOrigin)) {
return ResponseAccessMode.SAME_ORIGIN;
}
// If the browser blocked the request with a CORS error, treat the response
// as opaque regardless of any Access-Control-Allow-Origin header value.
if (request.corsErrorStatus()) {
return ResponseAccessMode.OPAQUE_CROSS_ORIGIN;
}
const allowOriginHeader = request.responseHeaderValue('access-control-allow-origin')?.trim();
if (!allowOriginHeader) {
return ResponseAccessMode.OPAQUE_CROSS_ORIGIN;
}
const isCredentialed = isRequestCredentialed(request);
// Under the Fetch specification, wildcard '*' is forbidden from exposing responses if credentials are used.
if (allowOriginHeader === '*' && !isCredentialed) {
return ResponseAccessMode.CORS_ALLOWED;
}
if (allowOriginHeader.toLowerCase() === effectiveInitiatorOrigin.siteId().toLowerCase()) {
return ResponseAccessMode.CORS_ALLOWED;
}
return ResponseAccessMode.OPAQUE_CROSS_ORIGIN;
}
/**
* Filters the response headers of a network request based on its access mode.
*
* - `SAME_ORIGIN`: Returns all response headers.
* - `CORS_ALLOWED`: Returns CORS-safelisted headers plus headers exposed via
* `Access-Control-Expose-Headers`.
* - `OPAQUE_CROSS_ORIGIN`: Returns only CORS-safelisted response headers. Under the Fetch
* specification, opaque responses do not expose headers configured by `Access-Control-Expose-Headers`.
*
* @param request The network request whose headers are being filtered.
* @param accessMode The evaluated response access mode for this request.
* @returns Array of allowed header name/value pairs.
*/
export function getFilterableResponseHeaders(
request: NetworkRequest.NetworkRequest,
accessMode: ResponseAccessMode,
): Array<{name: string, value: string}> {
if (accessMode === ResponseAccessMode.SAME_ORIGIN) {
return request.responseHeaders;
}
if (accessMode === ResponseAccessMode.OPAQUE_CROSS_ORIGIN) {
return request.responseHeaders.filter(header => {
const lowerName = header.name.toLowerCase().trim();
return CORS_SAFELISTED_RESPONSE_HEADERS.has(lowerName);
});
}
// For CORS_ALLOWED requests, parse Access-Control-Expose-Headers.
const exposeHeadersValue = request.responseHeaderValue('access-control-expose-headers')?.trim() || '';
const exposedSet = new Set(
exposeHeadersValue.split(',').map(h => h.trim().toLowerCase()).filter(Boolean),
);
const isCredentialed = isRequestCredentialed(request);
const allowWildcardExposure = !isCredentialed && exposedSet.has('*');
return request.responseHeaders.filter(header => {
const lowerName = header.name.toLowerCase().trim();
return CORS_SAFELISTED_RESPONSE_HEADERS.has(lowerName) || allowWildcardExposure || exposedSet.has(lowerName);
});
}