diff --git a/BUG-BOUNTY.md b/BUG-BOUNTY.md index 755ac74c9..a2fd3418e 100644 --- a/BUG-BOUNTY.md +++ b/BUG-BOUNTY.md @@ -21,13 +21,16 @@ Out-of-scope: The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting: -* **XSS / CORS / token issues scoped to `api.puter.com`.** The API origin holds no sensitive session cookies; the user session lives on `puter.com`. +* **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we *do* evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host. * **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources. * **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable. * **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design. * **App metadata or app user-count "leaks".** This information is currently public by design. * **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors. -* **Missing PKCE or other OIDC hardening** where the provider's token is already verified over TLS. Please open a GitHub issue/PR for hardening suggestions. +* **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own. +* **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability. +* **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported. +* **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape. * **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm). * **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel). diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index 7e8c7b40c..eadf9d134 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -337,7 +337,7 @@ describe('MeteringService', () => { const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); // Previous usage was 0 (under the allowance) — one big request that - // blows past the limit is legitimate and shouldn't page. + // blows straight past several multiples is legitimate, not abuse. await target.incrementUsage( bigActor, 'ai:chat', @@ -349,11 +349,11 @@ describe('MeteringService', () => { alarmSpy.mockRestore(); }); - it('alarms on the next expense once already at or past the limit', async () => { + it('does not alarm on further usage past the limit until the next multiple is crossed', async () => { const overActor: Actor = { user: makeUser() }; const sub = await target.getActorSubscription(overActor); - // First expense takes them exactly to the limit — no alarm yet. + // Take them just over the allowance (into the 1x–2x band). await target.incrementUsage( overActor, 'ai:chat', @@ -361,11 +361,36 @@ describe('MeteringService', () => { sub.monthUsageAllowance, ); - // Spy only on the *second* expense, which lands while already at - // the limit — this is the one that should flag. + // A small further expense stays within the same band — no new + // multiple crossed, so it shouldn't page. const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); await target.incrementUsage(overActor, 'ai:chat', 1, 1_000); + expect(wasOveruseAlarmed(alarmSpy)).toBe(false); + alarmSpy.mockRestore(); + }); + + it('alarms when a whole multiple of the allowance is crossed while already over', async () => { + const overActor: Actor = { user: makeUser() }; + const sub = await target.getActorSubscription(overActor); + + // First expense takes them to the limit (1x) — no alarm yet. + await target.incrementUsage( + overActor, + 'ai:chat', + 1, + sub.monthUsageAllowance, + ); + + // Spy only on the expense that crosses into 2x while already over. + const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); + await target.incrementUsage( + overActor, + 'ai:chat', + 1, + sub.monthUsageAllowance, + ); + expect(alarmSpy).toHaveBeenCalledWith( expect.stringContaining('usage exceeded'), expect.stringContaining('exceeded their usage allowance'), @@ -377,8 +402,12 @@ describe('MeteringService', () => { it('does not alarm while purchased credits still cover the overage', async () => { const creditActor: Actor = { user: makeUser() }; const sub = await target.getActorSubscription(creditActor); - await target.updateAddonCredit(creditActor.user.uuid, 5_000_000); + await target.updateAddonCredit( + creditActor.user.uuid, + 5_000_000_000, + ); + // Cross to 2x — would page if not for the credits covering it. await target.incrementUsage( creditActor, 'ai:chat', @@ -387,7 +416,12 @@ describe('MeteringService', () => { ); const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); - await target.incrementUsage(creditActor, 'ai:chat', 1, 1_000); + await target.incrementUsage( + creditActor, + 'ai:chat', + 1, + sub.monthUsageAllowance, + ); expect(wasOveruseAlarmed(alarmSpy)).toBe(false); alarmSpy.mockRestore(); diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index 623b3ed0a..f08a4fdfc 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -897,18 +897,26 @@ export class MeteringService extends PuterService { // No metered allowance to exceed (e.g. unlimited policies) — nothing to flag. if (!(allowance > 0)) return; - // Only alarm if the actor was ALREADY at or past their allowance before - // this expense arrived. A single large request that crosses the limit in - // one shot (previous usage still under the allowance) is legitimate and - // shouldn't page — we only flag sustained overuse, i.e. the next expense - // that lands while they're already over. const previousUsage = actorUsages.total - incrementCost; + const allowedMultiple = Math.floor(actorUsages.total / allowance); + const previousMultiple = Math.floor(previousUsage / allowance); + + // Only alarm if the actor was ALREADY at or past their allowance before + // this expense arrived. A single large request that jumps past the limit + // in one shot (previous usage still under the allowance) is legitimate + // and shouldn't page. const wasAlreadyOverLimit = previousUsage >= allowance; + // And only when this expense crosses into a new whole multiple of the + // allowance (2x, 3x, …) rather than on every expense once over — that + // first-over multiple is 2x, since being already over means the previous + // multiple was at least 1. + const crossedMultiple = previousMultiple < allowedMultiple; const hasNoAddonCredit = (actorAddons.purchasedCredits || 0) <= (actorAddons.consumedPurchaseCredits || 0); - if (!(wasAlreadyOverLimit && hasNoAddonCredit)) return; + if (!(wasAlreadyOverLimit && crossedMultiple && hasNoAddonCredit)) + return; this.clients.alarm.create( `metering usage exceeded by user: ${actor.user?.username}`,