diff options
| author | Adam Soltys <[email protected]> | 2026-08-28 11:11:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-28 11:11:32 -0700 |
| commit | a7fc7959eeb58fa7230c7e0869012f03ad2b23b7 (patch) | |
| tree | 1a10473f87903e792c10f48f43c5fe0c3dcc42e4 | |
| parent | 0080e6231cd5f23dfb9260e1a846153b01904903 (diff) | |
| download | coinos-server-a7fc7959eeb58fa7230c7e0869012f03ad2b23b7.tar.gz coinos-server-a7fc7959eeb58fa7230c7e0869012f03ad2b23b7.zip | |
fund: authenticated recovery of a rotated fund's old link
When a fund's secret id has been rotated (invalidating an exfiltrated id list),
its old id 404s. Let a legit owner who bookmarked the old link find the fund
again — but reveal the new id ONLY to an authenticated manager of the new fund,
via a `fund:rotated:<oldid>` -> newid map. An anonymous caller or a non-owner
gets the same "fund not found" as before, so an attacker replaying a leaked old
id learns nothing (an open old->new redirect would defeat the rotation). The
/fund/:id route now runs `optional` auth so req.user is available without
requiring it; bearer funds with no managers are intentionally not recoverable
this way. rotate-funds.ts records the mapping on any future rotation.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
| -rw-r--r-- | index.ts | 2 | ||||
| -rw-r--r-- | routes/payments.ts | 33 |
2 files changed, 30 insertions, 5 deletions
@@ -79,7 +79,7 @@ app.post("/payments", auth, payments.create); app.get("/payments", auth, payments.list); app.get("/payments/:hash", payments.get); app.post("/parse", auth, payments.parse); -app.get("/fund/:id", payments.fund); +app.get("/fund/:id", optional, payments.fund); // app.get("/fund/:name/withdraw", auth, payments.withdraw); app.get("/fund/:name/managers", payments.managers); app.post("/fund/managers", auth, payments.addManager); diff --git a/routes/payments.ts b/routes/payments.ts index 6505917d..487885bd 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -261,10 +261,30 @@ export default { const { params: { id }, } = req; - const amount = await g(`fund:${id}`); + let amount = await g(`fund:${id}`); + let fid = id; + + // A rotated fund no longer exists at its OLD id (security fix 2026-08-25: + // fund secret uids were rotated to invalidate an exfiltrated id list). Serve + // the new fund ONLY to an authenticated MANAGER of it — never anonymously or + // to a non-owner. An open old->new redirect would defeat the rotation, since + // the attacker holds the leaked OLD ids; gating on manager membership means a + // replayed old id reveals nothing unless you already control the fund. The + // route's `optional` auth populates req.user without requiring it. Bearer + // funds (no managers) are intentionally not recoverable this way. + if (typeof amount === "undefined" || amount === null) { + const rotatedTo = await g(`fund:rotated:${id}`); + const uid = req.user?.id; + if (rotatedTo && uid && (await db.sIsMember(`fund:${rotatedTo}:managers`, uid))) { + fid = rotatedTo; + amount = await g(`fund:${rotatedTo}`); + } + } + if (typeof amount === "undefined" || amount === null) return bail(res, "fund not found"); - let payments = (await db.lRange(`fund:${id}:payments`, 0, -1)) || []; + + let payments = (await db.lRange(`fund:${fid}:payments`, 0, -1)) || []; payments = await Promise.all(payments.map((hash) => gf(`payment:${hash}`))); await Promise.all( @@ -273,8 +293,13 @@ export default { payments = payments.filter((p) => p); - const authorization = await g(`authorization:${id}`); - res.send({ amount, authorization: authorization?.amount, payments }); + const authorization = await g(`authorization:${fid}`); + res.send({ + amount, + authorization: authorization?.amount, + payments, + ...(fid !== id ? { id: fid, rotatedFrom: id } : {}), + }); }, // async withdraw(req, res) { |
