Skip to content

Commit b4eab56

Browse files
Harden URL logging and cache key warnings
Add redactSensitiveUrlQuery helper and export it from the public API. - OpenFetchError.toShape now redacts sensitive query parameters from URLs by default. - Debug plugin redacts URLs in logged metadata. - createCacheMiddleware emits a one-time console.warn when Authorization or Cookie is present without varyHeaderNames or a custom cache key; add suppressAuthCacheKeyWarning to opt out. Include unit tests, security test updates, and README/SECURITY documentation. Made-with: Cursor
1 parent 1953491 commit b4eab56

24 files changed

Lines changed: 371 additions & 12 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Register **`retry` before `timeout`** so retries wrap the full inner stack. Use
8585

8686
**Retry timing:** `retry.timeoutTotalMs` measures elapsed time with a monotonic clock (`performance.now()` when available), so the budget is not skewed by system clock changes. By default (`retry.enforceTotalTimeout !== false`), each attempt merges a deadline into the request `signal` so an in-flight `fetch` aborts when the budget runs out (`ERR_RETRY_TIMEOUT`). Set `retry.enforceTotalTimeout: false` to enforce the budget only between attempts. `retry.timeoutPerAttemptMs` sets `timeout` for every attempt inside the retry middleware. Each `dispatch` uses `clearTimeout` in a `finally` block so per-attempt timers are not left dangling.
8787

88-
**Debug:** Default logs omit request headers. Use `debug({ includeRequestHeaders: true, maskHeaders: ["authorization"], maskStrategy: "partial" })` for values like `Bearer ****abcd`, or `maskStrategy: "hash"` for a short fingerprint. **`maskHeaderValues`** supports the same strategies when building your own logs.
88+
**Debug:** Default logs omit request headers. Logged URLs **redact common sensitive query parameters** (`token`, `code`, `password`, …); set `maskUrlQuery: false` to log raw URLs (avoid in production). Use `debug({ includeRequestHeaders: true, maskHeaders: ["authorization"], maskStrategy: "partial" })` for values like `Bearer ****abcd`, or `maskStrategy: "hash"` for a short fingerprint. **`maskHeaderValues`** supports the same strategies when building your own logs.
8989

9090
### Execution model
9191

@@ -102,7 +102,7 @@ Understanding order helps avoid surprises with retries, timeouts, and escape hat
102102

103103
### Memory cache and authentication
104104

105-
The default cache key is ``METHOD fullUrl``. For **authenticated or per-user** GETs, also pass header names that affect the response so entries do not leak across users:
105+
The default cache key is ``METHOD fullUrl``. The first request with **`Authorization` or `Cookie`** and no `varyHeaderNames` / custom `key` triggers a **one-time `console.warn`** (suppress with `suppressAuthCacheKeyWarning: true` if you only cache public data). For **authenticated or per-user** GETs, also pass header names that affect the response so entries do not leak across users:
106106

107107
```ts
108108
createCacheMiddleware(store, {
@@ -129,7 +129,7 @@ For URLs influenced by untrusted input, call `assertSafeHttpUrl(url)` before req
129129

130130
### Errors and logging
131131

132-
`OpenFetchError.toShape()` omits `config.auth` but may still include **response `data` and `headers`**. For client-facing or shared logs, use `toShape({ includeResponseData: false, includeResponseHeaders: false })`. The error instance itself can still hold full `config`; do not expose it raw.
132+
`OpenFetchError.toShape()` omits `config.auth` and by default **redacts sensitive query parameters** in the `url` field; pass `redactSensitiveUrlQuery: false` only for trusted diagnostics. It may still include **response `data` and `headers`**. For client-facing or shared logs, use `toShape({ includeResponseData: false, includeResponseHeaders: false })`. The error instance itself can still hold full `config`; do not expose it raw.
133133

134134
## Documentation
135135

SECURITY.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ openfetch is a thin `fetch` wrapper. Callers supply URLs, headers, and bodies. T
77
- **Axios-class proxy CVEs (e.g. CVE-2025-62718 / `NO_PROXY` normalization)** — openfetch does **not** implement axios-style `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` matching. Outbound routing follows the host runtime’s `fetch` (and any platform proxy). Those CVEs therefore do **not** map to openfetch code paths; policy still belongs at the app, proxy, or mesh layer.
88

99
- **Network trust** — You choose endpoints. Blocking private IPs, metadata hosts, or open redirects is an **application** concern for partially trusted URLs.
10-
- **Secrets**`toShape()` on `OpenFetchError` avoids echoing `config.auth`, but the full `Error` object may still carry `config` (including credentials). Response bodies and headers in `toShape()` may still contain tokens or PII; use `toShape({ includeResponseData: false, includeResponseHeaders: false })` when serializing for untrusted clients or broad logs. Never send raw errors to untrusted clients without redaction.
10+
- **Secrets**`toShape()` on `OpenFetchError` avoids echoing `config.auth`, but the full `Error` object may still carry `config` (including credentials). Response bodies and headers in `toShape()` may still contain tokens or PII; use `toShape({ includeResponseData: false, includeResponseHeaders: false })` when serializing for untrusted clients or broad logs. By default, `toShape()` also **redacts common sensitive query parameters** in the serialized `url` (for example `token`, `code`, `password`); use `redactSensitiveUrlQuery: false` only for trusted diagnostics. The `debug()` plugin applies the same redaction to logged URLs. Never send raw errors to untrusted clients without redaction.
1111
- **Supply chain** — Install this package from npm or a verified Git tag; verify integrity with your package manager.
1212

1313
## Server-side usage and SSRF
@@ -34,6 +34,8 @@ Mitigations (combine as appropriate):
3434

3535
Unauthenticated, fully public GETs may keep the default key.
3636

37+
The middleware emits a **one-time `console.warn`** the first time it sees `Authorization` or `Cookie` on a cacheable request while `varyHeaderNames` is empty and no custom `key` is set. Suppress with `suppressAuthCacheKeyWarning: true` when you know the cache is safe (for example anonymous-only endpoints).
38+
3739
## Retry and non-idempotent methods
3840

3941
By default, `createRetryMiddleware` retries network/parse failures and configured HTTP error statuses **only** for `GET`, `HEAD`, `OPTIONS`, and `TRACE`, to reduce duplicate side effects (for example double charges on `POST`).

dist/core/cache.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ export type CacheMiddlewareOptions = {
3434
* Use for authenticated or personalized GETs, e.g. `["authorization", "cookie"]`, so entries do not leak across users.
3535
*/
3636
varyHeaderNames?: string[];
37+
/**
38+
* When false (default), the first cached GET/HEAD that includes `Authorization` or `Cookie`
39+
* without `varyHeaderNames` or a custom `key` triggers a one-time `console.warn` about
40+
* possible cross-user cache leakage. Set true if you intentionally cache anonymous responses
41+
* only or use another isolation mechanism.
42+
*/
43+
suppressAuthCacheKeyWarning?: boolean;
3744
};
3845
/** Append a stable suffix from header values so cache keys differ per auth/cookie (etc.). */
3946
export declare function appendCacheKeyVaryHeaders(baseKey: string, headers: Record<string, string> | undefined, headerNames: string[]): string;

dist/core/cache.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core/cache.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ function shallowCloneResponse(r) {
3333
data: r.data,
3434
};
3535
}
36+
function headersHaveAuthOrCookie(headers) {
37+
if (!headers)
38+
return false;
39+
for (const k of Object.keys(headers)) {
40+
const l = k.toLowerCase();
41+
if (l === "authorization" || l === "cookie")
42+
return true;
43+
}
44+
return false;
45+
}
3646
/** Append a stable suffix from header values so cache keys differ per auth/cookie (etc.). */
3747
export function appendCacheKeyVaryHeaders(baseKey, headers, headerNames) {
3848
if (!headerNames.length)
@@ -87,6 +97,7 @@ export function createCacheMiddleware(store, options) {
8797
const varyHeaderNames = options?.varyHeaderNames ?? [];
8898
const methods = new Set((options?.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()));
8999
const inflight = new Map();
100+
let authCacheKeyWarningIssued = false;
90101
return async (ctx, next) => {
91102
if (ctx.request.memoryCache?.skip) {
92103
await next();
@@ -100,6 +111,16 @@ export function createCacheMiddleware(store, options) {
100111
const urlString = buildURL(ctx.request.url, ctx.request);
101112
const rawKey = options?.key?.({ request: ctx.request, url: urlString }) ??
102113
`${method} ${urlString}`;
114+
if (!authCacheKeyWarningIssued &&
115+
options?.suppressAuthCacheKeyWarning !== true &&
116+
options?.key === undefined &&
117+
varyHeaderNames.length === 0 &&
118+
headersHaveAuthOrCookie(ctx.request.headers)) {
119+
authCacheKeyWarningIssued = true;
120+
if (typeof console !== "undefined" && typeof console.warn === "function") {
121+
console.warn("[openfetch] createCacheMiddleware: request uses Authorization or Cookie but varyHeaderNames is empty and no custom key is set; cache entries may be shared across users. Use varyHeaderNames: [\"authorization\", \"cookie\"] or options.key, or set suppressAuthCacheKeyWarning: true if this is intentional.");
122+
}
123+
}
103124
const key = appendCacheKeyVaryHeaders(rawKey, ctx.request.headers, varyHeaderNames);
104125
const ttlMs = ctx.request.memoryCache?.ttlMs ?? defaultTtl;
105126
const swrMs = ctx.request.memoryCache?.staleWhileRevalidateMs ?? defaultSwr;

dist/core/error.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export type OpenFetchErrorToShapeOptions = {
2323
* Default true (backward compatible).
2424
*/
2525
includeResponseHeaders?: boolean;
26+
/**
27+
* When true (default), replaces sensitive query parameter values in the serialized `url`
28+
* (e.g. `token`, `code`, `api_key`). Set false only for trusted internal diagnostics.
29+
*/
30+
redactSensitiveUrlQuery?: boolean;
31+
/** Extra query parameter names to redact (case-insensitive); merged with the built-in list. */
32+
sensitiveQueryParamNames?: string[];
2633
};
2734
export declare class OpenFetchError<T = unknown> extends Error {
2835
config?: OpenFetchConfig;

dist/core/error.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core/error.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { buildURL } from "../helpers/buildURL.js";
2+
import { redactSensitiveUrlQuery, } from "../helpers/redactUrlQuery.js";
23
function resolveUrl(config) {
34
if (config?.url === undefined || config.url === "")
45
return "";
@@ -32,9 +33,14 @@ export class OpenFetchError extends Error {
3233
* Use `includeResponseData: false` and `includeResponseHeaders: false` when serializing for untrusted parties.
3334
*/
3435
toShape(options) {
35-
const url = this.request?.url ??
36+
let url = this.request?.url ??
3637
resolveUrl(this.config) ??
3738
"";
39+
const redactOpts = {
40+
enabled: options?.redactSensitiveUrlQuery !== false,
41+
paramNames: options?.sensitiveQueryParamNames,
42+
};
43+
url = redactSensitiveUrlQuery(url, redactOpts);
3844
const method = (this.config?.method ?? "GET").toUpperCase();
3945
const includeData = options?.includeResponseData !== false;
4046
const includeHeaders = options?.includeResponseHeaders !== false;

dist/helpers/redactUrlQuery.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** Lowercase query parameter names whose values are redacted in logs and error shapes. */
2+
export declare const DEFAULT_SENSITIVE_QUERY_PARAM_NAMES: readonly ["token", "access_token", "refresh_token", "id_token", "password", "passwd", "secret", "client_secret", "api_key", "apikey", "auth", "code", "session", "sessionid", "sid", "csrf", "nonce"];
3+
export type RedactUrlQueryOptions = {
4+
/** Extra parameter names (case-insensitive) to redact. */
5+
paramNames?: string[];
6+
/** When false, returns `url` unchanged. Default true. */
7+
enabled?: boolean;
8+
};
9+
/**
10+
* Replaces values of sensitive query parameters for safe logging or serialization.
11+
* Invalid or non-absolute URLs are returned unchanged.
12+
*/
13+
export declare function redactSensitiveUrlQuery(url: string, options?: RedactUrlQueryOptions): string;
14+
//# sourceMappingURL=redactUrlQuery.d.ts.map

dist/helpers/redactUrlQuery.d.ts.map

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)