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:
2026-08-04 15:57:22 +05:30
co-authored by Claude Opus 5
parent 72e4d3eceb
commit c64434a131
10 changed files with 369 additions and 45 deletions
+37 -13
View File
@@ -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,