Any signed-in user can make unlimited TypeSafe calls on the server's key through the icon route, even with signals_on_server off #340
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by the security-audit skill (run-1, 2026-10-20). Source-read only: nothing was run, so this is a lead that still needs its local check, not a confirmed finding. Two independent verifiers did not disprove it.
POST /api/signals/icon (pick_signal_icon) takes any authenticated User, with no admin check, and calls Jev on the server's TYPESAFE_KEY whenever that variable is set. It gets the key from typesafe_key(), not server_key(), so the admin's signals_on_server choice (off by default, meaning each person pays with their own key) does not apply. The code comment says this is deliberate: the icon pick is on the server's key because it is the server's page asking, 'once a Signal'. Nothing enforces that frequency. The handler is not tied to a Signal. It takes a free name, keywords, description and 1-60 candidates with labels of up to 200 characters, and has no per-user count, rate limit or cache. Each request is one billed TypeSafe call. A household user can loop on it to run up the operator's TypeSafe bill. Every call also takes a slot in the one process-wide NEXT_CALL pace, which Signals judging and key checks share, and a 429 from TypeSafe makes back_off hold every caller. So a flood also delays everyone's Signals judging, by roughly 1/60 s or tokens/80,000 s per call. With signals_on_server on, add_signal has no per-user cap on Signals either, but there the admin has chosen to pay for every Signal, so that part is weaker and secondary.
Claimed root cause. Calls on the server's TypeSafe key have no per-user budget or rate control. pick_icon's documented 'once a Signal' is not enforced, because the route is not bound to a Signal still waiting for an icon. The route also selects the key with typesafe_key(), so it spends the server key even when the admin has signals_on_server off, which docs/signals.md describes as each person using a key of their own.
Local check. {'local': 'In a sandbox, run a scratch daemon with TYPESAFE_KEY=dummy, signals_on_server=false and IPX_TYPESAFE_URL pointed at a local stub that counts requests and answers a valid icon choice. As a non-admin dummy user, POST /api/signals/icon 100 times with {"name":"x","keywords":["x"],"candidates":"solid/star","star"}. Expect 100 stub requests carrying 'Bearer dummy', and ipx_jev billing for that user under key=server. Then make the stub return 429 with Retry-After: 30 once, and observe a concurrent Signals pass held for about 30 s. Regression test: the route rejects repeats from one user past a small budget, or requires a Signal id the user owns that has no icon yet.', 'deployment': "Owner-observed and read only: check whether the iPX container's environment sets TYPESAFE_KEY while Admin, Server shows signals_on_server off. In /metrics, compare jev billing rows with key=server for non-admin users against the number of Signals they own. Check the TypeSafe account's spend and rate-limit settings."}
Owner check (no probing). Owner-observed and read only: check whether the iPX container's environment sets TYPESAFE_KEY while Admin, Server shows signals_on_server off. In /metrics, compare jev billing rows with key=server for non-admin users against the number of Signals they own. Check the TypeSafe account's spend and rate-limit settings.
Full detail:
/config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md(fingerprintipx:web.rs:pick_signal_icon:unbounded-server-key-spend).Fixed in
0de0fb4: each person may have Jev pick 20 icons an hour; past that /api/signals/icon answers 429 and the page shows it. Deployed.