fix(security): close SSRF, credential-leak, and auth bypass findings in 0.8.4
Audit of 0.8.4 found the repo's own gates green, so these came from manual
review; each is covered by a new regression test.
security/fetch.ts
- safeFetch re-attached Authorization/Cookie on a same-origin redirect that
followed a cross-origin hop (a -> b -> b), handing credentials to the second
host. Compare against the origin the caller trusted, not the previous hop.
- The private-network guard resolved the host, approved it, then let fetch
resolve again, so a low-TTL record could answer public for the check and
private for the connection. Pin the connection to the validated address,
preserving Host and TLS serverName. Opt out with pinDns: false.
- 0:0:0:0:0:ffff:127.0.0.1, ::ffff:7f00:1 and fec0::1 were not treated as
private. Add uncompressed IPv4-mapped forms, site-local IPv6, 198.18/15
and 192.0.0/24.
security/url.ts
- sanitizeUrl returned "//evil.com" verbatim via the relative-path fast path,
bypassing the host checks it had just run; in an href that navigates
cross-origin. Resolve protocol-relative input instead.
dev-server/gateway.ts
- Malformed base64 in an Authorization header threw out of checkAuth on an
unauthenticated path. Fail closed.
- split(":", 2) truncated passwords at the first colon, so a password
containing ":" could never authenticate.
- The credential compare short-circuited on length mismatch, leaking length
by timing. Extracted as verifyBasicAuth so it is testable.
authz/index.ts
- Namespace wildcards only matched the first segment, so "post:comment:*"
did not grant "post:comment:delete". Match at every depth.
uploader/operations.ts
- Validate transcoder dimensions and bitrate rather than trusting the declared
type, and reject ".." path segments.
package.json
- The brace-expansion override pinned 5.0.8, which is inside the advisory
range >=4.0.0 <5.0.9. Bump to 5.0.9; bun audit is now clean.
Verified: check:production passes (typecheck, lint, 1033 tests, format,
ASVS, public-API baseline, editor checks).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -175,14 +175,46 @@ function gatewayWebSocketOriginAllowed(
|
||||
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
|
||||
}
|
||||
|
||||
/** Constant-time-ish string compare. */
|
||||
/**
|
||||
* Constant-time string compare. Length is folded into the accumulator rather
|
||||
* than short-circuiting, so a wrong guess cannot be distinguished from a
|
||||
* wrong-length guess by timing.
|
||||
*/
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
let diff = a.length ^ b.length;
|
||||
const max = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < max; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an HTTP Basic `Authorization` header against the configured pairs.
|
||||
* Malformed base64 fails closed rather than throwing, and the username and
|
||||
* password are split on the FIRST colon so passwords may contain colons.
|
||||
*/
|
||||
export function verifyBasicAuth(
|
||||
header: string | null | undefined,
|
||||
pairs: readonly { user: string; pass: string }[],
|
||||
): boolean {
|
||||
if (!header?.startsWith("Basic ")) return false;
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = atob(header.slice(6));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const separator = decoded.indexOf(":");
|
||||
if (separator === -1) return false;
|
||||
const user = decoded.slice(0, separator);
|
||||
const pass = decoded.slice(separator + 1);
|
||||
// Evaluate every pair so the number of configured credentials is not
|
||||
// observable through response timing.
|
||||
return pairs.reduce(
|
||||
(ok, p) => (timingSafeEqual(user, p.user) && timingSafeEqual(pass, p.pass)) || ok,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
export function internalError(res: Response): string | null {
|
||||
const encoded = res.headers.get("x-wrnexus-internal-error");
|
||||
if (!encoded) return null;
|
||||
@@ -267,15 +299,7 @@ async function checkAuth(
|
||||
|
||||
if (auth.basic) {
|
||||
const pairs = Array.isArray(auth.basic) ? auth.basic : [auth.basic];
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const ok =
|
||||
header.startsWith("Basic ") &&
|
||||
(() => {
|
||||
const [user, pass] = atob(header.slice(6)).split(":", 2);
|
||||
return pairs.some(
|
||||
(p) => timingSafeEqual(user ?? "", p.user) && timingSafeEqual(pass ?? "", p.pass),
|
||||
);
|
||||
})();
|
||||
const ok = verifyBasicAuth(req.headers.get("authorization"), pairs);
|
||||
if (!ok) {
|
||||
return new Response("Authentication required", {
|
||||
status: 401,
|
||||
|
||||
Reference in New Issue
Block a user