aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-28 11:11:32 -0700
committerAdam Soltys <[email protected]>2026-08-28 11:11:32 -0700
commita7fc7959eeb58fa7230c7e0869012f03ad2b23b7 (patch)
tree1a10473f87903e792c10f48f43c5fe0c3dcc42e4
parent0080e6231cd5f23dfb9260e1a846153b01904903 (diff)
downloadcoinos-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.ts2
-rw-r--r--routes/payments.ts33
2 files changed, 30 insertions, 5 deletions
diff --git a/index.ts b/index.ts
index 9ce509c2..755aa827 100644
--- a/index.ts
+++ b/index.ts
@@ -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) {