fix: scope apikey export to the caller for non-admins
CI / format (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 23s
CI / install-and-build (pull_request) Successful in 46s
CI / typecheck-tests (pull_request) Successful in 30s
CI / typecheck-source (pull_request) Successful in 31s
CI / test (pull_request) Successful in 40s
CI / format (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 23s
CI / install-and-build (pull_request) Successful in 46s
CI / typecheck-tests (pull_request) Successful in 30s
CI / typecheck-source (pull_request) Successful in 31s
CI / test (pull_request) Successful in 40s
A non-admin's forced export filter includes co-resident *user* records so abode/resident data isn't left with dangling references, but the same `users` allowlist was also governing `apikey` records — leaking co-residents' apikey metadata (name/permissions/expiry, though not the secret token). Add a dedicated `apikeys` uid-allowlist to ExportFilter that scopes apikey records specifically, falling back to `users` when absent (so existing unfiltered/voluntary-narrowing behaviour and the round-trip are unchanged). computeForcedExportFilter now sets it to the caller alone (intersected with an apikey credential's restrict_users), so a non-admin can only ever export their own keys. Global admins (forced filter null) are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,12 @@ async function seed(db: TestDb["db"]): Promise<Seed> {
|
||||
permissions: {},
|
||||
expires_at: null,
|
||||
});
|
||||
await db.createApikey({
|
||||
uid: co.uid,
|
||||
name: "co key",
|
||||
permissions: {},
|
||||
expires_at: null,
|
||||
});
|
||||
const note1 = await db.createNote(
|
||||
{
|
||||
aid: abode1.aid,
|
||||
@@ -312,6 +318,9 @@ describe("exportScope: computeForcedExportFilter", () => {
|
||||
new Set([s.normal.uid, s.co.uid]),
|
||||
);
|
||||
assert.ok(!forced!.users!.includes(s.admin.uid));
|
||||
// apikeys are self-only, even though co is a co-resident whose user
|
||||
// record is exported for referential integrity.
|
||||
assert.deepEqual(forced!.apikeys, [s.normal.uid]);
|
||||
|
||||
// A caller requesting a wider abode never gets it: intersection, not union.
|
||||
const effective = intersectExportFilters(
|
||||
@@ -557,6 +566,17 @@ describe("GET /export endpoint", () => {
|
||||
assert.ok(userUids.has(s.co.uid));
|
||||
assert.ok(!userUids.has(s.admin.uid), "admin not a co-resident of aid1");
|
||||
|
||||
// apikeys are self-only: the caller's own key is exported, but a
|
||||
// co-resident's key metadata is NOT, even though their user record is.
|
||||
const apikeyUids = lines
|
||||
.filter((l) => l.kind === "apikey")
|
||||
.map((l) => l.data.uid);
|
||||
assert.deepEqual(new Set(apikeyUids), new Set([s.normal.uid]));
|
||||
assert.ok(
|
||||
!apikeyUids.includes(s.co.uid),
|
||||
"co-resident apikey metadata must not leak",
|
||||
);
|
||||
|
||||
// The meta line records the *effective* (narrowed) filter.
|
||||
const meta = lines.find((l) => l.kind === "meta");
|
||||
assert.ok(meta);
|
||||
|
||||
Reference in New Issue
Block a user