-
Notifications
You must be signed in to change notification settings - Fork 2
Restler Security
Every request runs with real credentials on the wire — an Authorization
header, an API key in the query string, a token in the body. RESTler cannot
stop you from logging a request (via the call/authFailure events) or an
error (via RESTlerError.context), so it redacts the copies handed to both
before they reach your code. This page is the full contract: what's
covered, what deliberately isn't, and how to extend it.
The request/response actually sent over the wire is never altered by any of this — only the copies handed to event listeners and error contexts are.
| Surface | What's stripped |
|---|---|
call / authFailure event request argument |
Sensitive headers, url query values + userinfo, payload (omitted entirely) |
call / authFailure event response argument |
url only (query values + userinfo) — headers and body pass through unchanged |
RESTlerError.context.request (any error _makeRequest throws, including one your _responseHandler throws) |
Same as the event request: headers, url, payload
|
A transport error's cause chain (DNS/TLS/connection failure) |
The raw request URL is replaced with its redacted form in every message/stack in the chain |
A RESTlerConfigError's context.value (bad auth/headers/tls option) |
auth.password/auth.token, sensitive header values, tls.key/tls.keyFile PEM content |
By default a header is "sensitive" if its name (case-insensitively) is one of
Authorization, Cookie, Proxy-Authorization, X-Api-Key, X-Auth-Token,
PRIVATE-TOKEN, or X-Amz-Security-Token — the standard credential headers
plus the common non-standard token headers a CUSTOM-auth override typically
sets. A redacted header's value becomes the literal string [REDACTED]; the
header name is kept, so a log still shows which headers were sent.
A redacted URL keeps its scheme/host/path and every query-string key, but
replaces every query-string value with [REDACTED] and strips any
user:pass@ userinfo from the authority — so an API key injected via
endpoint.query (see Authentication) never
appears, however it was carried.
import { RESTler } from '@tundralibs/restler';
import type { RESTlerRequest, RESTlerResponse } from '@tundralibs/restler';
class VendorAPI extends RESTler {
public readonly vendor = 'vendor';
/** Test/demo seam: stub the transport without touching global `fetch`. */
public setFetch(fn: typeof fetch): void {
this._fetch = fn;
}
getSecret() {
return this._makeRequest<{ ok: boolean }>({
path: '/secret',
method: 'GET',
});
}
}
const api = new VendorAPI({
baseURL: 'https://api.example.com',
auth: { type: 'BEARER', token: 'super-secret-token' },
});
let wireAuth: string | null = null;
let eventRequest: RESTlerRequest | undefined;
api.setFetch((_input, init) => {
// The REAL request still carries the real token...
wireAuth = new Headers(init?.headers as HeadersInit).get('authorization');
return Promise.resolve(
new Response('{"ok":true}', {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
});
api.on(
'call',
(_vendor, request: RESTlerRequest, _response: RESTlerResponse) => {
// ...but the event's copy never sees it.
eventRequest = request;
},
);
await api.getSecret();
console.log(wireAuth); // "BEARER super-secret-token"
console.log(eventRequest?.headers?.['Authorization']); // "[REDACTED]"Not redacted: the
payloadis omitted (not field-redacted) from the event/error copy — a request body is arbitrary in shape with no fixed set of secret-bearing keys to match, so dropping it entirely is the safe default. The response'sheadersandbodyare never touched (only itsurlis) — a vendor that echoes a credential back in a response header or body is outside what this redaction covers. And a credential you interpolate into an error's message text yourself, or place under a non-standardcontextkey, is likewise outside what the library can retroactively find.
A vendor-specific credential header (a driver's own connection-string header,
a non-standard token header) isn't in the default set. Override
_isSensitiveHeader — the single seam every redaction site routes through —
and chain to super so the base credential headers stay covered:
import { RESTler } from '@tundralibs/restler';
import type { RESTlerEndpoint, RESTlerRequest } from '@tundralibs/restler';
class InternalAPI extends RESTler {
public readonly vendor = 'internal';
public setFetch(fn: typeof fetch): void {
this._fetch = fn;
}
protected override _isSensitiveHeader(name: string): boolean {
return name.toLowerCase() === 'x-vendor-secret' ||
super._isSensitiveHeader(name);
}
protected override _authInjector(endpoint: RESTlerEndpoint): void {
endpoint.headers = { ...endpoint.headers, 'X-Vendor-Secret': 'shh' };
}
ping() {
return this._makeRequest({ path: '/ping', method: 'GET' });
}
}
const api = new InternalAPI({ baseURL: 'https://internal.example.com' });
api.setFetch(() => Promise.resolve(new Response('', { status: 200 })));
let redactedHeaders: RESTlerRequest['headers'];
api.on('call', (_vendor, request: RESTlerRequest) => {
redactedHeaders = request.headers;
});
await api.ping();
console.log(redactedHeaders?.['X-Vendor-Secret']); // "[REDACTED]"Match case-insensitively:
_isSensitiveHeaderis called with the header name exactly as it appears on the outbound request, and a vendor may send any casing.
When fetch itself fails before a response arrives (DNS, TLS, connection
refused), some runtimes embed the full request URL — query-string
credential and all — directly in the transport error's message, and
therefore its stack (Deno nests it a level down inside TypeError: fetch failed's own cause). That error is preserved as the wrapped
RESTlerRequestError's cause (and is the same object the call event's
4th argument points to), so without scrubbing, a cause-expanding logger —
console.error(err), util.inspect, Deno.inspect(err, { depth }) — would
still print the credential even though the redacted context.request doesn't
carry it.
RESTler scrubs every occurrence of the raw URL out of message and stack,
for every error reachable via cause, in place — so the chain keeps its
original error types and instanceof checks still hold; only the text
changes.
import { RESTler, RESTlerRequestError } from '@tundralibs/restler';
class VendorAPI extends RESTler {
public readonly vendor = 'vendor';
public setFetch(fn: typeof fetch): void {
this._fetch = fn;
}
ping(key: string) {
return this._makeRequest({ path: '/ping', method: 'GET', query: { key } });
}
}
const api = new VendorAPI({ baseURL: 'https://api.example.com' });
// Simulates a runtime that embeds the full failed URL — credential-bearing
// query string included — in its own transport error message (as Deno's
// `TypeError: fetch failed` does).
api.setFetch((input) => {
throw new TypeError(`fetch failed: ${input}`);
});
try {
await api.ping('abc-secret');
} catch (err) {
if (err instanceof RESTlerRequestError) {
console.log((err.cause as Error)?.message.includes('abc-secret')); // false
}
}This scrub only rewrites the chain when the URL actually carried something sensitive (a query string or userinfo) — a failure whose URL has neither is left completely untouched, so genuine transport diagnostics (host, path, port) survive for debugging.
A RESTlerConfigError thrown for an invalid auth, headers, or tls
option carries the rejected value in its context.value — with
auth.password/auth.token masked, sensitive header values redacted, and a
tls private key (key/keyFile) masked, the same way a request's headers
are. A bad baseURL, port, timeout, version, contentType, or
socketPath carries its rejected value unmasked, since none of those are
credential-shaped.
RESTlerTimeoutError.messageis currently a literal, un-interpolated string — it readsRequest timed out after ${request.timeout}sverbatim (the${...}placeholder text, not the actual timeout value), because the source builds it with single quotes rather than a template literal. This doesn't affect redaction (the redactedcontext.requestis still correct) orinstanceofnarrowing — only the message text is wrong. Match oninstanceof RESTlerTimeoutError, not on parsing the message, until this is fixed upstream.