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:
@@ -52,11 +52,12 @@ export function defineRbac(roles: Record<string, string[]>): Rbac {
|
||||
if (!subject?.roles?.length) return false;
|
||||
const perms = permissionsFor(subject.roles);
|
||||
if (perms.has("*") || perms.has(permission)) return true;
|
||||
// Namespace wildcards: "post:*" grants "post:write".
|
||||
const ns = permission.includes(":")
|
||||
? permission.slice(0, permission.indexOf(":")) + ":*"
|
||||
: null;
|
||||
return ns ? perms.has(ns) : false;
|
||||
// Namespace wildcards at every depth: "post:*" and "post:comment:*" both
|
||||
// grant "post:comment:delete".
|
||||
for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) {
|
||||
if (perms.has(`${permission.slice(0, at)}:*`)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { defineRbac } from "../src/index.ts";
|
||||
|
||||
describe("RBAC namespace wildcards", () => {
|
||||
const rbac = defineRbac({
|
||||
admin: ["*"],
|
||||
editor: ["post:*"],
|
||||
moderator: ["post:comment:*"],
|
||||
reader: ["post:read"],
|
||||
});
|
||||
|
||||
test("a wildcard grants every depth beneath it", () => {
|
||||
expect(rbac.can({ roles: ["editor"] }, "post:write")).toBe(true);
|
||||
expect(rbac.can({ roles: ["editor"] }, "post:comment:delete")).toBe(true);
|
||||
expect(rbac.can({ roles: ["editor"] }, "post:comment:flag:undo")).toBe(true);
|
||||
});
|
||||
|
||||
test("a deeper wildcard grants its own subtree", () => {
|
||||
expect(rbac.can({ roles: ["moderator"] }, "post:comment:delete")).toBe(true);
|
||||
expect(rbac.can({ roles: ["moderator"] }, "post:comment:flag:undo")).toBe(true);
|
||||
});
|
||||
|
||||
test("a wildcard does not leak sideways or upward", () => {
|
||||
expect(rbac.can({ roles: ["moderator"] }, "post:write")).toBe(false);
|
||||
expect(rbac.can({ roles: ["moderator"] }, "post")).toBe(false);
|
||||
expect(rbac.can({ roles: ["editor"] }, "page:write")).toBe(false);
|
||||
expect(rbac.can({ roles: ["reader"] }, "post:write")).toBe(false);
|
||||
});
|
||||
|
||||
test("root wildcard and unknown subjects behave", () => {
|
||||
expect(rbac.can({ roles: ["admin"] }, "anything:at:all")).toBe(true);
|
||||
expect(rbac.can({ roles: [] }, "post:read")).toBe(false);
|
||||
expect(rbac.can(undefined, "post:read")).toBe(false);
|
||||
});
|
||||
|
||||
test("role inheritance terminates on cycles", () => {
|
||||
const cyclic = defineRbac({ a: ["role:b", "p:a"], b: ["role:a", "p:b"] });
|
||||
expect([...cyclic.permissionsFor(["a"])].sort()).toEqual(["p:a", "p:b"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user