Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 734715f461 Merge branch 'feat/app-user-feedback' of https://github.com/HeyPuter/puter into feat/app-user-feedback 2026-08-12 13:32:34 -07:00
jelveh b290c1d922 Merge remote-tracking branch 'origin/main' into feat/app-user-feedback 2026-08-12 09:22:00 -07:00
jelveh dd068e31d0 Require verified users for app feedback API
Add `requireVerified: true` to both app feedback routes (`GET /target` and `POST /`) in `AppFeedbackController`. This tightens access control so only verified user accounts can fetch feedback targets or submit app feedback.
2026-08-12 09:13:11 -07:00
jelveh 90cffbfcde refactor: prep the feedback cap error once instead of via a factory
Both throw sites are in one call and only one can ever run, so a plain
const reads the same and drops a function that existed only to defer a
constructor.
2026-08-11 22:11:24 -07:00
jelveh 25acf14c8b test: cover the feedback service and store layers directly
The service owns every feedback business rule — target resolution,
eligibility, message normalization, the durable caps, and the owner-email
preconditions — but was only reachable through the controller's tests. Give
it and the store their own suites so a regression names the layer it broke.

Service coverage adds the branches the route tests could not reach: a
blocked origin resolving to null rather than surfacing a 403, owners who are
suspended or unsubscribed, length measured after normalization, the 24h cap
window boundary, subject-header injection via the app title, and the email
links being rooted at config.origin.

The two describes already labelled `AppFeedbackService ...` move out of the
controller test, which keeps only the caller-facing promise that a failed
send still returns success.
2026-08-11 22:10:04 -07:00
Nariman Jelveh be781c5f1b fix: restore the feedback Send button's gradient on hover
1c2d33f14 added :not(:disabled) to the base .app-feedback-btn hover
rule, which raised its specificity above the primary button's hover rule
— so hovering Send swapped the blue gradient for the light hover
background, leaving white text on a pale pill. Give the primary hover
rule the same :not(:disabled) so it wins by source order again.
2026-08-11 19:57:56 -07:00
Nariman Jelveh 4cb0730875 feat: point the feedback email footer at the Dev Center
The footer told developers to turn feedback off via a puter.apps.update
one-liner, but the toggle lives in the Dev Center app settings — and
Dev Center is where apps get feedback enabled by default in the first
place. Reword it to 'manage it in the Dev Center' with a link built
from config.origin (like app_link) so it holds on self-hosted
deployments.
2026-08-11 19:19:38 -07:00
Nariman Jelveh 00b2fcdea3 docs: document the COOP false-resolve limitation of showFeedbackDialog
Under COOP the popup's opener link is severed, so the SDK deliberately
resolves false while the popup stays open and the user can still submit
(a feedback submission has no server read-back the way a permission
grant does). The documented contract ('resolves to true if the user
submitted feedback') was silently wrong on cross-origin-isolated pages —
state the limitation in the doc and the SDK jsdoc: false means 'not
confirmed', not 'not sent'.
2026-08-11 18:31:39 -07:00
Nariman Jelveh f70347b579 docs: state exactly what showFeedbackDialog shares with the developer
The doc claimed the dialog 'tells the user their username will be
shared', while the dialog's note talked only about the email address and
the implementation shares the username always plus the email (as
Reply-To) only when verified. Describe the actual disclosure: username
always, email when verified.
2026-08-11 18:31:02 -07:00
Nariman Jelveh 3d9691b11a fix: stop HTML-escaping email subject lines
Subjects were compiled with default Handlebars escaping, so the
app-user-feedback subject rendered a title like "Bob's App & Games" as
"Bob's App & Games" — literal entities in the recipient's mail
client. Subjects are plain-text headers, not HTML; compile them with
noEscape. Header safety is unaffected: the transport encodes newlines
and free-form values collapse whitespace upstream.
2026-08-11 18:30:29 -07:00
Nariman Jelveh c2fe813929 fix: make the feedback dialog's privacy note match what is shared
The note unconditionally said 'Your email address will be shared with
the developer so they can respond', but AppFeedbackService shares the
username always and the email only when it exists and is verified — an
unverified or temp-user sender was promised a reply path that never
materializes, and nobody was told about the username.

Show 'username and email' when the signed-in user's email is verified,
and 'username' otherwise.
2026-08-11 18:29:22 -07:00
Nariman Jelveh ef7fc05dcc fix: reject feedback origins longer than the source_origin column
readTargetParam accepted values up to 3000 chars but the raw origin is
stored verbatim into source_origin VARCHAR(2048) (MySQL/Postgres), so a
2049-3000 char origin passed every validation and then blew up the
INSERT with an HTTP 500 on Postgres/strict MySQL — or was silently
truncated on non-strict MySQL, corrupting the abuse-forensics value the
column exists for. Cap the param at the column size.
2026-08-11 18:27:42 -07:00
Nariman Jelveh 1c2d33f149 fix: gate feedback dialog Cancel/X on an in-flight submit
Escape and backdrop clicks were already ignored while the POST was
pending, but the X, Close, and Cancel buttons weren't — clicking one
mid-send settled the promise false and tore down the overlay while the
submission still landed server-side: the developer got the email, the
app was told sent=false, and a user who resubmitted 'the failed one'
sent a duplicate and burned a daily-cap slot.

Apply the same !sending gate to the buttons and disable them visually
while the send is in flight.
2026-08-11 18:26:47 -07:00
Nariman Jelveh b1fdc8d665 fix: carry feedback_enabled in suggested/recommended app summaries
launch_app uses options.app_obj verbatim when provided, and the
suggested-apps launch paths (open_item.js, UIWindowSearch.js,
UIDesktop.js) pass summaries from toAppSummary — which omitted
feedback_enabled. So an opted-in editor launched by opening a .txt file
showed no Send Feedback control in the dashboard drawer, while the same
app launched from the Apps tab (full puter.apps.get object) did.
2026-08-11 18:23:43 -07:00
Nariman Jelveh 1ecc23cf83 fix: guard app-triggered feedback dialog against desktop-lockout loops
The showFeedbackDialog IPC handler had no re-entry or abuse guard, and
the dialog it opens is a full-viewport overlay above the taskbar and
every window — so 'while (true) await puter.ui.showFeedbackDialog()'
kept the desktop permanently covered (for signed-out users, the same
loop spams the full-page signup window instead). Every dismissal just
settled the promise and let the app immediately reopen it.

Allow one dialog at a time, and back off reopens per app after each
dismissal that sent nothing: 10s, then 60s, then blocked until page
reload. A successful send resets the backoff, and user-initiated paths
(dashboard drawer) are unaffected since they don't go through IPC.
2026-08-11 18:21:43 -07:00
Nariman Jelveh 7230580b18 fix: settle showFeedbackDialog instead of hanging on older host GUIs
In the app environment showFeedbackDialog awaited an IPC reply with no
capability check. A host GUI that predates this feature (self-hosted
Puter running the live js.puter.com SDK) has no handler for the message
and never replies, so the promise documented as 'never rejects' also
never resolved.

The GUI now advertises the IPC dialogs it can answer via a
puter.gui_features param on the app iframe URL, and the SDK resolves
false when 'feedback-dialog' isn't listed. A reply timeout could not
substitute: legitimate replies only arrive when the user closes the
dialog, so any timeout would false-negative while the user is typing.
Older SDKs ignore the extra param.
2026-08-11 18:19:46 -07:00
Nariman Jelveh 1460751b41 fix: refuse feedback when the deployment cannot deliver it
With no email transport configured (the common self-hosted default),
submissions were stored in app_feedback — a table with no read path
beyond the abuse-cap COUNTs — the owner email was silently skipped, and
the sender was still shown 'Feedback sent. Thank you!'. The developer
never learns the feedback exists while the user believes it was
delivered.

Gate acceptsFeedback on clients.email.isConfigured so the pre-flight
reports enabled:false (the dialog shows its 'not accepting feedback'
pane) and submit returns 403 instead of swallowing messages. Owner-level
store-without-email cases (unconfirmed owner email, per-app email cap
overflow) keep their existing deliberate semantics.
2026-08-11 18:16:14 -07:00
Nariman Jelveh 4f96d371f4 fix: don't mint a user-app token as a side effect of the feedback popup
Every embedded_in_popup boot ran the user-app token exchange, and the
exchange is a write: /auth/get-user-app-token bootstraps an app row for
the opener origin, grants flag:app-is-authenticated (what makes the
site count as connected to the account), and creates its AppData dir.
So merely opening — or immediately cancelling — a send-feedback popup
recorded a user<->site relationship the read-only feedback flow never
needs: the server resolves the feedback target from the attested origin
without any of it.

Gate the exchange behind runsUserAppTokenExchange(action) in all three
popup paths that mint (main postAuthActions exchange, temp-user signup
success, manual signup fallback). request-permission keeps the exchange
since grants are written against the app row it bootstraps.
2026-08-11 18:13:13 -07:00
Nariman Jelveh c5cf1dc390 docs: disclose the Dev Center's feedback-on-by-default in SDK docs/types
The Dev Center deliberately creates apps with feedbackEnabled (see
32c950de5), but the SDK docs, apps.d.ts, and AppFeedbackService's class
doc all described feedback as strictly opt-in / default-false with no
qualification — so a Dev Center developer reading them would wrongly
conclude feedback is off for their app. State the Dev Center behavior
alongside the API default, and correct the update-path docs: an omitted
feedbackEnabled leaves the current value unchanged rather than
defaulting to false.
2026-08-11 18:07:47 -07:00
Nariman Jelveh 503a450b40 fix: make feedback daily caps fail closed under concurrent submissions
The per-user and per-user-per-app caps were check-then-insert and the
per-app email cap was count-then-send, so parallel requests (or multiple
nodes, or the route limiter failing open) could all read a stale
under-cap count and push past every limit — the exact scenario the
DB-backed caps exist to stop.

Now the user caps recount after the insert (own row included) and roll
the row back with 429 if a burst breached them, and the email cap claims
its slot (email_sent=1) before sending, recounts, and releases the slot
if over cap or if the send fails.
2026-08-11 18:06:05 -07:00
Nariman Jelveh f4dfbd735b fix: resolve 'app-'-prefixed app names in feedback target lookup
APP_NAME_REGEX allows names beginning with "app-" (e.g. the seeded
app-center), but resolveTargetApp's startsWith('app-') heuristic sent
those to a uid-only lookup with no name fallback, so feedback for such
apps 403'd even when enabled. Use AppStore.resolveApp (uid, then name)
like the rest of the codebase.
2026-08-11 18:03:15 -07:00
Nariman Jelveh 81402918d2 fix: feedback modal polish + share sender email
Address four issues with the app feedback UI:

- Dashboard app-drawer: the extra "feedback" control pushed the close
  button past the drawer's derived width and clipped it. A `has-feedback`
  modifier widens the surface by one button + gap so all three controls
  fit. The control's glyph is now a message bubble with text lines, which
  reads more clearly at 14px than the previous bare speech bubble.

- The feedback dialog is no longer a UIWindow. It's a from-scratch
  overlay modal in the spirit of the dashboard modals (uninstall,
  add-app): a fixed scrim + centered card with self-contained,
  theme-aware color tokens (light default + dark override), a bottom-sheet
  layout on narrow screens, backdrop/Escape close, and an entrance
  transition. This renders consistently across the three contexts it's
  opened from (desktop app-IPC, dashboard drawer, standalone popup), so
  the callers no longer pass UIWindow-specific window_options.

- Feedback now shares the sender's email (not just their username) with
  the developer so they can respond: the owner email sets Reply-To to the
  sender and shows the address in the body — but only when the sender's
  email is verified (an unverified address could be anyone's, so it's
  never used as a reply target). EmailClient.send gains an optional
  replyTo. The dialog note now says the email will be shared.

Tests: e2e updated for the new modal (7 pass); backend feedback suite
covers the verified/unverified sender-email split (sqlite + postgres);
EmailClient + GUI unit suites pass; type-check clean.
2026-08-11 17:11:36 -07:00
Nariman Jelveh 32c950de5b feat: enable app feedback by default in Dev Center
New apps created in Dev Center now have feedbackEnabled set on creation,
so users can send the developer feedback without any extra setup. A "User
Feedback" toggle in the app's settings lets developers turn it off (and
back on); it's wired into the save payload, the dirty-state tracking, and
the reset-to-original path like the neighboring toggles.

The Save update omits feedbackEnabled unless the toggle is present, and
the backend leaves an omitted field untouched, so the default survives
the create-then-save flow Dev Center runs. Add an SDK apps-suite guard
covering that round-trip (create-on -> unrelated update keeps it -> can
be turned off).
2026-08-11 16:47:12 -07:00
Nariman Jelveh 792361507e feat: add feedback control to the dashboard app-drawer
Surface the feedback dialog directly from the app window's chrome in
dashboard mode: apps that opt in (apps.feedbackEnabled) get a "Send
Feedback" button in the dashboard app-drawer, next to minimize/close.
It opens the same UIWindowAppFeedback dialog, targeting this app by uid.

The control is only rendered when the app opted in — feedback_enabled is
threaded from the launched app's metadata into the window options — and
the dialog still re-checks opt-in server-side, so a stale flag can't send
anywhere. Reuses the existing .dashboard-app-drawer-btn styling and the
app_feedback_title i18n string, so no new CSS or strings.

Adds e2e coverage: the control appears and opens the dialog for an
opted-in app, and is absent for an app that hasn't opted in.
2026-08-11 16:39:05 -07:00
Nariman Jelveh 4f267fb83e feat: app user feedback system
Add puter.ui.showFeedbackDialog(), letting users send feedback to an
app's developer. In the app environment the Puter desktop renders the
dialog; on a third-party website a puter.com popup hosts it. The message
is stored in a new app_feedback table and emailed to the app owner's
confirmed email — it never passes through the app's own code.

Feedback is strictly opt-in per app via a new apps.feedback_enabled
column (a real column, not an app-metadata key, so Dev Center's
whole-blob metadata saves can't silently erase it), settable through the
existing puter.apps.update path (feedbackEnabled).

Backend follows the layered stack: AppFeedbackStore (durable count
queries) -> AppFeedbackService (opt-in check, message normalization,
abuse caps, best-effort owner email) -> AppFeedbackController
(POST /app-feedback, GET /app-feedback/target). New app-user-feedback
email template uses the escaping-safe nl2br triple-stash.

Defensive by design:
- requireUserActor blocks app tokens, so feedback can't be submitted
  programmatically; guiOriginOnly keeps cross-origin pages out.
- App identity comes only from the validated IPC sender (desktop) or the
  browser-attested opener origin (popup), never from message contents.
- The send-feedback popup action is in NON_AUTH_POPUP_ACTIONS, so it
  never delivers a token to the opener.
- Layered limits: route rate limits, plus DB-count caps that fail closed
  when the limiter backend is down, plus a per-app daily owner-email cap.
- Owner email is fully best-effort: an unconfigured transport,
  unconfirmed/unsubscribed/suspended owner, or send failure never fails
  the request or blocks storage.
- The dialog and SDK method are resolve-only and always settle, so a
  caller is never left hanging.

Migrations for sqlite/mysql/postgres, puter.js types, docs, backend
tests (sqlite + postgres), and a Playwright e2e spec are included.
2026-08-11 16:31:27 -07:00
14 changed files with 361 additions and 448 deletions

No files matched your search

+35 -39
View File
@@ -6,33 +6,33 @@ We at **Puter** are committed to maintaining a secure experience for our users a
The following are in scope for this program:
- **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
- **`puter.com`**
- **`api.puter.com`**
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
* **`puter.com`**
* **`api.puter.com`**
Out-of-scope:
- Third-party services, applications, or libraries not maintained by Puter.
- Social engineering attacks (e.g., phishing against staff).
- Denial of Service (DoS), spam, or volumetric attacks.
- Physical security issues.
* Third-party services, applications, or libraries not maintained by Puter.
* Social engineering attacks (e.g., phishing against staff).
* Denial of Service (DoS), spam, or volumetric attacks.
* Physical security issues.
## Known Non-Issues (Please Check Before Submitting)
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 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, 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).
* **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, 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).
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
@@ -40,7 +40,7 @@ If you believe you have a **genuinely new** exploit chain that defeats one of th
To participate, you must:
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see _Proof of Reproduction_ below).
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see *Proof of Reproduction* below).
2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
@@ -51,30 +51,26 @@ Reports that do not meet these guidelines may not be eligible for a reward.
## Proof of Reproduction
Reports **MUST** demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
Reports must demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
- Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
- The **observed** result versus the **expected** result.
- For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process.
* Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
* The **observed** result versus the **expected** result.
* For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
* For server-side bugs: a runnable proof-of-concept.
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and not eligible for bounty**.
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and are generally not eligible**. If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
Please submit **one issue per report**.
Bundled "audit packs" of many speculative findings will not be elligible for bounty; send each confirmed issue separately.
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be elligible for bounty.
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
## Reporting Process
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
Include:
- A description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested remediation (if available)
- Paypal id where we can send the bounty (if eligible)
* A description of the vulnerability
* Steps to reproduce
* Potential impact
* Suggested remediation (if available)
We aim to acknowledge receipt within **72 hours** and provide a resolution timeline.
@@ -82,10 +78,10 @@ We aim to acknowledge receipt within **72 hours** and provide a resolution timel
We offer monetary rewards based on the severity of the vulnerability, as determined by our internal assessment (using CVSS as a guide).
- **Critical: \$1,000 – \$2,000**
- **High: \$500 – \$1,000**
- **Medium: \$200 – \$500**
- **Low: \$50 – \$100**
* **Critical: \$1,000 – \$2,000**
* **High: \$500 – \$1,000**
* **Medium: \$200 – \$500**
* **Low: \$50 – \$100**
Non-security issues, suggestions, and best practices feedback are always welcome, but may not qualify for a reward.
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive.
-78
View File
@@ -206,84 +206,6 @@ describe('whoami extension — handleWhoami', () => {
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
});
it('never exposes phone, card fingerprint or signup identity', async () => {
const user = await seedUser();
await server.stores.user.update(user.id as number, {
phone: '+15551234567',
card_fingerprint: 'fp_ABC123',
requires_phone_verification: false,
requires_card_verification: false,
});
// Guard against a vacuous assertion below: the columns really do hold
// the values we then expect never to see on the wire.
const stored = await server.stores.user.getById(user.id as number, {
cached: false,
force: true,
});
expect(stored?.phone).toBe('+15551234567');
expect(stored?.card_fingerprint).toBe('fp_ABC123');
for (const actor of [
{ user: { uuid: user.uuid, id: user.id as number } },
{
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
},
]) {
const { res, captured } = makeRes();
await runWithContext({ actor }, () =>
handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
expect(body.phone).toBeUndefined();
expect(body.card_fingerprint).toBeUndefined();
expect(body.password).toBeUndefined();
expect(body.otp_secret).toBeUndefined();
expect(body.signup_ip).toBeUndefined();
// Not just absent as a key — the values must not appear anywhere
// in the payload (nested under metadata, taskbar items, …).
const serialized = JSON.stringify(body);
expect(serialized).not.toContain('5551234567');
expect(serialized).not.toContain('fp_ABC123');
// The verification flags, which the GUI acts on, still ship.
expect(body).toHaveProperty('requires_phone_verification');
expect(body).toHaveProperty('requires_card_verification');
}
});
it('scrubs sensitive keys added to metadata, and leaves the cached row intact', async () => {
const user = await seedUser();
await server.stores.user.updateMetadata(user.id as number, {
tmp_password: 'bootstrap-secret',
billing: { card_fingerprint: 'fp_NESTED', tier: 'pro' },
});
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
const metadata = body.metadata as Record<string, unknown>;
expect(metadata.tmp_password).toBeUndefined();
// Nested sensitive keys are removed too; siblings survive.
expect(metadata.billing).toEqual({ tier: 'pro' });
expect(JSON.stringify(body)).not.toContain('fp_NESTED');
// The scrub works on a copy — server-side state is untouched.
const fresh = await server.stores.user.getById(user.id as number, {
cached: false,
force: true,
});
expect(fresh?.metadata?.tmp_password).toBe('bootstrap-secret');
expect(
(fresh?.metadata?.billing as Record<string, unknown>)
?.card_fingerprint,
).toBe('fp_NESTED');
});
it('marks the user as oidc_only when password is null', async () => {
const slug = Math.random().toString(36).slice(2, 8);
const oidcUser = await server.stores.user.create({
+3 -74
View File
@@ -33,68 +33,6 @@ const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet<string> = new Set([
'prompt_user_when_navigation_away_from_puter',
]);
// Keys that must never leave the server, whoever put them on the response.
// `details` is an explicit pick, but the `whoami.details` event hands
// listeners the full UserRow next to the object they may write to, and
// `metadata` is a free-form blob — so any of these can arrive on the
// response without an edit to the pick above. The scrub runs last, over the
// whole payload, and is the one place that decides what "sensitive" means.
//
// Credentials and single-use tokens, the payment/phone identifiers used for
// verification (`card_fingerprint` is the Stripe fingerprint, stable per
// card number), the network identity recorded at signup, and internal
// anti-abuse bookkeeping. `requires_phone_verification` /
// `requires_card_verification` stay: they are the flags the GUI acts on, and
// they carry no identifier.
const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
'password',
'tmp_password',
'pass_recovery_token',
'email_confirm_code',
'email_confirm_token',
'change_email_confirm_token',
'otp_secret',
'otp_recovery_codes',
'card_fingerprint',
'phone',
'clean_email',
'signup_ip',
'signup_ip_forwarded',
'signup_user_agent',
'signup_origin',
'signup_server',
'audit_metadata',
]);
// Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds
// at any level (`metadata` and `taskbar_items` are both nested structures).
const scrubSensitive = (
value: unknown,
seen: Set<object> = new Set(),
depth = 0,
): void => {
if (depth > 8 || value === null || typeof value !== 'object') return;
if (seen.has(value as object)) return;
seen.add(value as object);
if (Array.isArray(value)) {
for (const entry of value) scrubSensitive(entry, seen, depth + 1);
return;
}
for (const key of Object.keys(value as Record<string, unknown>)) {
if (SENSITIVE_KEYS.has(key)) {
delete (value as Record<string, unknown>)[key];
continue;
}
scrubSensitive(
(value as Record<string, unknown>)[key],
seen,
depth + 1,
);
}
};
export const handleWhoami = async (
req: Request,
res: Response,
@@ -136,12 +74,8 @@ export const handleWhoami = async (
}
}
// Deep-copied (it is decoded JSON) so the scrub below edits the response
// and not the cached UserRow. Sensitive keys inside it — tmp_password and
// anything else on the denylist — are removed by scrubSensitive.
const metadata = user.metadata
? structuredClone(user.metadata)
: user.metadata;
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
if (metadata) delete metadata.tmp_password;
const details: Record<string, unknown> = {
username: user.username,
@@ -150,9 +84,7 @@ export const handleWhoami = async (
unconfirmed_email: user.email,
email_confirmed: user.email_confirmed || user.username === 'admin',
requires_email_confirmation: user.requires_email_confirmation,
// The phone number itself is deliberately absent: nothing on the
// client reads it, and it is PII that would otherwise be handed to
// every app actor. Only the verification flag ships.
phone: user.phone,
requires_phone_verification: user.requires_phone_verification,
requires_card_verification: user.requires_card_verification,
desktop_bg_url: user.desktop_bg_url,
@@ -264,9 +196,6 @@ export const handleWhoami = async (
delete subscription.offering.price_id;
}
// Last word on what ships, after every listener has had its say.
scrubSensitive(details);
res.json(details);
};
@@ -1101,7 +1101,8 @@ export class AuthController extends PuterController {
is_temp: user!.password === null && user!.email === null,
ip:
(req?.headers?.['x-forwarded-for'] as
string | undefined) ||
| string
| undefined) ||
(
req as unknown as {
connection?: { remoteAddress?: string };
@@ -212,30 +212,6 @@ const makeRes = (): MockRes => {
return res;
};
/**
* Run the call route and return the rejection, or null when it resolves.
* Lets a test assert on *which* gate rejected without depending on how far
* an admitted call gets afterwards.
*/
const callForError = async (
routes: Captured,
req: Request,
actor: Actor,
): Promise<{ statusCode?: number; legacyCode?: string } | null> => {
try {
await runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
);
return null;
} catch (e) {
return e as { statusCode?: number; legacyCode?: string };
}
};
const makeReq = (body: Record<string, unknown> = {}, actor?: Actor): Request =>
({
body,
@@ -362,7 +338,8 @@ describe('DriverController.#handleCall (via captured router)', () => {
it('admits a bare session ("root" token) actor on the AI drivers', async () => {
// Privileged ("godmode") apps call AI with the user's own session
// token, so the AI drivers no longer set `noUserSession`: the
// session actor must get past the credential-shape gate.
// session actor reaches the permission scan (403 forbidden — no
// perms granted) instead of the credential-shape rejection.
const actor = await makeUserActor();
const req = makeReq(
{
@@ -372,8 +349,18 @@ describe('DriverController.#handleCall (via captured router)', () => {
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
});
it('admits app and access-token actors on the AI drivers (they fail later on permission, not credential shape)', async () => {
@@ -398,15 +385,29 @@ describe('DriverController.#handleCall (via captured router)', () => {
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({
statusCode: 403,
// Fresh actors hold no service:* perms, so the call still
// 403s — but at the permission scan, proving the
// credential-shape gate let the delegated actor through.
legacyCode: 'forbidden',
});
}
});
it('admits a user-scoped worker session on the AI drivers', async () => {
// Workers deployed without an app binding authenticate as a user
// actor whose session row is kind='worker' — they must keep their
// AI access rather than be rejected for their credential shape.
// AI access (they fail later on permission here, not on
// credential shape).
const base = await makeUserActor();
const actor: Actor = {
...base,
@@ -420,13 +421,24 @@ describe('DriverController.#handleCall (via captured router)', () => {
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
});
it('still allows bare session actors on drivers without the noUserSession flag', async () => {
// puter-kvstore doesn't set the flag, so the session actor must get
// past the credential-shape gate.
// puter-kvstore doesn't set the flag; the session actor reaches
// the permission scan (403 forbidden — no perms granted) instead
// of being rejected for its credential shape.
const actor = await makeUserActor();
const req = makeReq(
{
@@ -436,24 +448,24 @@ describe('DriverController.#handleCall (via captured router)', () => {
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
});
it('rejects with 403 when the actor lacks the service permission', async () => {
// A scoped access token holds only the permissions written to its
// own row, so it is the actor shape that can still be denied — the
// `driver`/`service` grants every user inherits are resolved from
// the issuer only for full-access tokens.
const base = await makeUserActor();
const actor: Actor = {
...base,
accessToken: {
uid: `tok-${uuidv4()}`,
issuer: base,
fullAccess: false,
},
};
// A fresh user has no service:* perms → permission.check returns
// false → 403.
const actor = await makeUserActor();
const req = makeReq(
{
interface: 'puter-kvstore',
@@ -462,8 +474,15 @@ describe('DriverController.#handleCall (via captured router)', () => {
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err).toMatchObject({ statusCode: 403 });
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('returns the wrapped {success, result, service} envelope on a successful call', async () => {
@@ -297,7 +297,8 @@ export class DriverController extends PuterController {
if (req.actor) {
const permService = this.services.permission as unknown as
PermissionService | undefined;
| PermissionService
| undefined;
if (permService) {
// Build via PermissionUtil.join so any `:` in a driver or
// interface name is escaped — raw interpolation would let a
@@ -64,7 +64,8 @@ export class NotificationController extends PuterController {
});
const notifService = this.services.notification as unknown as
NotificationService | undefined;
| NotificationService
| undefined;
if (notifService?.markAcknowledged) {
await notifService.markAcknowledged(uid, userId);
} else {
@@ -114,7 +115,8 @@ export class NotificationController extends PuterController {
});
const notifService = this.services.notification as unknown as
NotificationService | undefined;
| NotificationService
| undefined;
if (notifService?.markShown) {
await notifService.markShown(uid, userId);
} else {
@@ -574,7 +574,8 @@ export class PuterAIController extends PuterController {
text: extractTextContent(
(
messageResult.message as
Record<string, unknown> | undefined
| Record<string, unknown>
| undefined
)?.content,
),
index: 0,
+46 -13
View File
@@ -88,22 +88,55 @@ const implicit_user_app_permissions = [
},
];
// Permissions every user actor holds, regardless of group membership.
//
// Roots only: a grant subsumes everything beneath it, so `service` already
// answers `service:puter-kvstore:ii:puter-kvstore`. Listing descendants adds
// entries no check can ever need.
//
// A floor, not a ceiling — anything that depends on *who* the user is belongs
// in the ACL as a `user_to_group_permissions` / `user_to_user_permissions`
// row. Adding a root here grants it to every user, temp accounts included.
const default_user_permissions = {
driver: {},
service: {},
// Pre-v2 each entry below carried a `policy: { 'rate-limit': { max, period } }`
// block attached by a `policyPerm('temp.kv')` / `policyPerm('user.es')` helper,
// and a `driverPolicies` table held the actual `{max, period}` values. Nothing
// ever read those policy blocks in v2 — rate limiting is now declared
// directly on each driver via `@Driver({ rateLimit })` / `readonly rateLimit`
// (see KVStoreDriver, AppDriver, SubdomainDriver, NotificationDriver), with
// per-subscription overrides via `bySubscription`. The grants themselves
// stay so the permission scan still emits a path for each group/permission;
// only the dead policy plumbing was removed.
const hardcoded_user_group_permissions = {
system: {
'ca342a5e-b13d-4dee-9048-58b11a57cc55': {
driver: {},
service: {},
feature: {},
'local-terminal:access': {},
},
'b7220104-7905-4985-b996-649fdcdb3c8f': {
driver: {},
service: {},
'service:hello-world:ii:hello-world': {},
'service:puter-kvstore:ii:puter-kvstore': {},
'driver:puter-kvstore': {},
'service:puter-notifications:ii:crud-q': {},
'service:puter-apps:ii:crud-q': {},
'service:puter-subdomains:ii:crud-q': {},
'service:apps:ii:crud-q': {},
'service:es\\Cnotification:ii:crud-q': {},
'service:es\\Capp:ii:crud-q': {},
'service:app:ii:crud-q': {},
'service:es\\Csubdomain:ii:crud-q': {},
},
'78b1b1dd-c959-44d2-b02c-8735671f9997': {
driver: {},
service: {},
'service:hello-world:ii:hello-world': {},
'service:puter-kvstore:ii:puter-kvstore': {},
'driver:puter-kvstore': {},
'service:es\\Cnotification:ii:crud-q': {},
'service:es\\Capp:ii:crud-q': {},
'service:app:ii:crud-q': {},
'service:es\\Csubdomain:ii:crud-q': {},
'service:apps:ii:crud-q': {},
},
},
};
module.exports = {
implicit_user_app_permissions,
default_implicit_user_app_permissions,
default_user_permissions,
hardcoded_user_group_permissions,
};
@@ -222,14 +222,14 @@ describe('PermissionService (integration)', () => {
const { actor } = await makeUserActor();
const allowed = await permService.check(
actor,
`zztest:nope-${uuidv4()}:ii:read`,
`service:nope-${uuidv4()}:ii:read`,
);
expect(allowed).toBeFalsy();
});
it('canManagePermission delegates to check on manage:<perm>', async () => {
const { user, actor } = await makeUserActor();
const perm = `zztest:manage-test-${uuidv4()}:ii:read`;
const perm = `service:manage-test-${uuidv4()}:ii:read`;
// Grant manage:<perm> via the flat store.
await server.stores.permission.setFlatUserPerm(
user.id,
@@ -253,7 +253,7 @@ describe('PermissionService (integration)', () => {
permService.grantUserUserPermission(
actor,
`does-not-exist-${uuidv4()}`,
'zztest:foo:ii:read',
'service:foo:ii:read',
),
).rejects.toMatchObject({ statusCode: 404 });
});
@@ -264,7 +264,7 @@ describe('PermissionService (integration)', () => {
permService.grantUserUserPermission(
actor,
user.username,
'zztest:foo:ii:read',
'service:foo:ii:read',
),
).rejects.toMatchObject({ statusCode: 400 });
});
@@ -277,7 +277,7 @@ describe('PermissionService (integration)', () => {
permService.grantUserUserPermission(
issuer,
target.username,
`zztest:unmanaged-${uuidv4()}:ii:read`,
`service:unmanaged-${uuidv4()}:ii:read`,
),
),
).rejects.toMatchObject({ statusCode: 403 });
@@ -286,7 +286,7 @@ describe('PermissionService (integration)', () => {
it('grant persists when issuer holds manage:<permission>', async () => {
const { user: issuer, actor: issuerActor } = await makeUserActor();
const { user: target, actor: targetActor } = await makeUserActor();
const permission = `zztest:user-user-${uuidv4()}:ii:read`;
const permission = `service:user-user-${uuidv4()}:ii:read`;
await server.stores.permission.setFlatUserPerm(
issuer.id,
`manage:${permission}`,
@@ -316,7 +316,7 @@ describe('PermissionService (integration)', () => {
permService.revokeUserUserPermission(
actor,
`does-not-exist-${uuidv4()}`,
'zztest:foo:ii:read',
'service:foo:ii:read',
),
).rejects.toMatchObject({ statusCode: 404 });
});
@@ -329,7 +329,7 @@ describe('PermissionService (integration)', () => {
permService.revokeUserUserPermission(
issuer,
target.username,
`zztest:unmanaged-${uuidv4()}:ii:read`,
`service:unmanaged-${uuidv4()}:ii:read`,
),
),
).rejects.toMatchObject({ statusCode: 403 });
@@ -359,7 +359,7 @@ describe('PermissionService (integration)', () => {
permService.grantUserAppPermission(
actor,
`does-not-exist-${uuidv4()}`,
'zztest:foo:ii:read',
'service:foo:ii:read',
),
),
).rejects.toMatchObject({ statusCode: 404 });
@@ -368,7 +368,7 @@ describe('PermissionService (integration)', () => {
it('persists a user→app grant and is idempotent', async () => {
const { user, actor } = await makeUserActor();
const app = await makeApp(user.id);
const permission = `zztest:gua-${uuidv4()}:ii:read`;
const permission = `service:gua-${uuidv4()}:ii:read`;
await runWithContext({ actor }, () =>
permService.grantUserAppPermission(actor, app.uid, permission),
@@ -389,7 +389,7 @@ describe('PermissionService (integration)', () => {
it('revokeUserAppPermission removes the row', async () => {
const { user, actor } = await makeUserActor();
const app = await makeApp(user.id);
const permission = `zztest:rua-${uuidv4()}:ii:read`;
const permission = `service:rua-${uuidv4()}:ii:read`;
await runWithContext({ actor }, () =>
permService.grantUserAppPermission(actor, app.uid, permission),
);
@@ -417,7 +417,7 @@ describe('PermissionService (integration)', () => {
permService.revokeUserAppPermission(
appActor,
app.uid,
'zztest:foo:ii:read',
'service:foo:ii:read',
),
).rejects.toMatchObject({ statusCode: 403 });
});
@@ -448,8 +448,8 @@ describe('PermissionService (integration)', () => {
const { user, actor } = await makeUserActor();
const app = await makeApp(user.id);
for (const p of [
`zztest:rua-${uuidv4()}:ii:read`,
`zztest:rua-${uuidv4()}:ii:write`,
`service:rua-${uuidv4()}:ii:read`,
`service:rua-${uuidv4()}:ii:write`,
]) {
await runWithContext({ actor }, () =>
permService.grantUserAppPermission(actor, app.uid, p),
@@ -488,7 +488,7 @@ describe('PermissionService (integration)', () => {
permService.grantDevAppPermission(
actor,
`does-not-exist-${uuidv4()}`,
'zztest:foo:ii:read',
'service:foo:ii:read',
),
),
).rejects.toMatchObject({ statusCode: 404 });
@@ -502,7 +502,7 @@ describe('PermissionService (integration)', () => {
permService.grantDevAppPermission(
actor,
app.uid,
`zztest:unmanaged-${uuidv4()}:ii:read`,
`service:unmanaged-${uuidv4()}:ii:read`,
),
),
).rejects.toMatchObject({ statusCode: 403 });
@@ -511,7 +511,7 @@ describe('PermissionService (integration)', () => {
it('grant persists when manage:<perm> is held', async () => {
const { user, actor } = await makeUserActor();
const app = await makeApp(user.id);
const permission = `zztest:dev-${uuidv4()}:ii:read`;
const permission = `service:dev-${uuidv4()}:ii:read`;
await server.stores.permission.setFlatUserPerm(
user.id,
`manage:${permission}`,
@@ -542,7 +542,7 @@ describe('PermissionService (integration)', () => {
permService.revokeDevAppPermission(
appActor,
app.uid,
'zztest:foo:ii:read',
'service:foo:ii:read',
),
).rejects.toMatchObject({ statusCode: 403 });
});
@@ -566,7 +566,7 @@ describe('PermissionService (integration)', () => {
permService.grantUserGroupPermission(
actor,
{ id: 1, uid: 'grp-doesnt-matter' },
`zztest:unmanaged-${uuidv4()}:ii:read`,
`service:unmanaged-${uuidv4()}:ii:read`,
),
),
).rejects.toMatchObject({ statusCode: 403 });
@@ -577,7 +577,7 @@ describe('PermissionService (integration)', () => {
permService.revokeUserGroupPermission(
{ user: undefined } as unknown as Actor,
{ id: 1, uid: 'grp-x' },
'zztest:foo:ii:read',
'service:foo:ii:read',
),
).rejects.toMatchObject({ statusCode: 403 });
});
@@ -587,7 +587,7 @@ describe('PermissionService (integration)', () => {
it('listUserPermissionIssuers returns the issuer who granted the target a perm', async () => {
const { user: issuer, actor: issuerActor } = await makeUserActor();
const { user: target } = await makeUserActor();
const permission = `zztest:lst-${uuidv4()}:ii:read`;
const permission = `service:lst-${uuidv4()}:ii:read`;
await server.stores.permission.setFlatUserPerm(
issuer.id,
`manage:${permission}`,
@@ -666,7 +666,7 @@ describe('PermissionService (integration)', () => {
it('revoke is visible immediately — a cached "granted" reading is not served', async () => {
const { user: issuer, actor: issuerActor } = await makeUserActor();
const { user: target, actor: targetActor } = await makeUserActor();
const permission = `zztest:revoke-now-${uuidv4()}:ii:read`;
const permission = `service:revoke-now-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
await runWithContext({ actor: issuerActor }, () =>
@@ -721,7 +721,7 @@ describe('PermissionService (integration)', () => {
it('grant is visible immediately — a cached "denied" reading is not served', async () => {
const { user: issuer, actor: issuerActor } = await makeUserActor();
const { user: target, actor: targetActor } = await makeUserActor();
const permission = `zztest:grant-now-${uuidv4()}:ii:read`;
const permission = `service:grant-now-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
// Prime a "denied" reading into the cache.
@@ -780,7 +780,7 @@ describe('PermissionService (integration)', () => {
const { user: issuer, actor: issuerActor } = await makeUserActor();
const { user: target, actor: targetActor } = await makeUserActor();
const app = await makeApp(target.id);
const permission = `zztest:app-revoke-now-${uuidv4()}:ii:read`;
const permission = `service:app-revoke-now-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
await runWithContext({ actor: issuerActor }, () =>
@@ -825,7 +825,7 @@ describe('PermissionService (integration)', () => {
const { user: issuer, actor: issuerActor } = await makeUserActor();
const { user: target, actor: targetActor } = await makeUserActor();
const app = await makeApp(target.id);
const permission = `zztest:app-grant-now-${uuidv4()}:ii:read`;
const permission = `service:app-grant-now-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
// App is allowed to act with the permission, but the user does
@@ -874,7 +874,7 @@ describe('PermissionService (integration)', () => {
it('revokeUserUserPermission deletes the linked SQL row before resolving', async () => {
const { user: issuer, actor: issuerActor } = await makeUserActor();
const { user: target } = await makeUserActor();
const permission = `zztest:rvk-sync-${uuidv4()}:ii:read`;
const permission = `service:rvk-sync-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
await runWithContext({ actor: issuerActor }, () =>
permService.grantUserUserPermission(
@@ -906,7 +906,7 @@ describe('PermissionService (integration)', () => {
it('revokeUserUserPermission surfaces a failed SQL delete instead of swallowing it', async () => {
const { user: issuer, actor: issuerActor } = await makeUserActor();
const { user: target } = await makeUserActor();
const permission = `zztest:rvk-fail-${uuidv4()}:ii:read`;
const permission = `service:rvk-fail-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
await runWithContext({ actor: issuerActor }, () =>
permService.grantUserUserPermission(
@@ -946,7 +946,7 @@ describe('PermissionService (integration)', () => {
it('scan-path warms of the flat view carry an expiry (grants are permanent)', async () => {
const { user: issuer, actor: issuerActor } = await makeUserActor();
const { user: target, actor: targetActor } = await makeUserActor();
const permission = `zztest:warm-ttl-${uuidv4()}:ii:read`;
const permission = `service:warm-ttl-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
// The linked (SQL) path is a delegation chain: it only grants
// if the issuer holds the permission themselves. Give the
@@ -1088,78 +1088,47 @@ describe('PermissionService — scan paths', () => {
{ ownerUserId },
);
describe('default user permissions and group grants', () => {
it('grants the default permissions to a member of the default user group', async () => {
describe('group-derived grants', () => {
it('members of the default user group inherit the system driver grants', async () => {
const { actor } = await makeGroupedUser();
expect(await permService.check(actor, 'driver:puter-kvstore')).toBe(
true,
);
expect(
await permService.check(
actor,
'service:puter-kvstore:ii:puter-kvstore',
),
).toBe(true);
});
it('grants the default permissions to a user in no group at all', async () => {
// The floor does not depend on membership, which is what repairs
// the account whose best-effort group insert failed at signup —
// previously locked out of every driver call with no recovery.
it('a user in no group inherits nothing', async () => {
const { actor } = await makeLooseUser();
expect(await permService.check(actor, 'driver:puter-kvstore')).toBe(
true,
false,
);
expect(
await permService.check(
actor,
'service:puter-kvstore:ii:puter-kvstore',
),
).toBe(true);
});
it('grants the default permissions to a temp user', async () => {
it('registerSystemGrantForUsers reaches the user group but not the temp group', async () => {
const permission = `feature:users-only-${uuidv4()}`;
const { actor: member } = await makeGroupedUser();
const { row: temp, actor: tempActor } = await makeLooseUser();
await server.stores.group.addUsers(DEFAULT_TEMP_GROUP_UID, [
temp.username,
]);
expect(
await permService.check(tempActor, 'driver:puter-kvstore'),
).toBe(true);
permService.registerSystemGrantForUsers(permission);
expect(await permService.check(member, permission)).toBe(true);
expect(await permService.check(tempActor, permission)).toBe(false);
});
it('does not grant a permission outside the default set', async () => {
// Both used to be admin-only entries in the group-keyed map.
// Nothing grants them now.
const { actor } = await makeLooseUser();
expect(await permService.check(actor, 'local-terminal:access')).toBe(
false,
);
expect(
await permService.check(actor, `feature:${uuidv4()}`),
).toBe(false);
});
it('registerSystemGrantForEveryone reaches temp users too', async () => {
const permission = `feature:everyone-${uuidv4()}`;
const { row: temp, actor: tempActor } = await makeLooseUser();
await server.stores.group.addUsers(DEFAULT_TEMP_GROUP_UID, [
temp.username,
]);
it('never queries group membership to resolve a user permission', async () => {
// The membership lookup existed only to re-derive the flattened
// constant above, so no scan should reach for it now.
const { actor } = await makeGroupedUser();
const spy = vi.spyOn(server.stores.group, 'listGroupsWithMember');
try {
expect(
await permService.check(actor, 'driver:puter-kvstore', {
noCache: true,
}),
).toBe(true);
expect(
await permService.check(actor, `zztest:${uuidv4()}:read`, {
noCache: true,
}),
).toBe(false);
expect(spy).not.toHaveBeenCalled();
} finally {
spy.mockRestore();
}
permService.registerSystemGrantForEveryone(permission, {
note: 'test',
});
expect(await permService.check(tempActor, permission)).toBe(true);
});
it('honours a group grant issued by a user, and drops it on revoke', async () => {
@@ -1403,22 +1372,6 @@ describe('PermissionService — scan paths', () => {
).toBe(false);
});
it('a scoped token does not inherit the default user permissions', async () => {
// The floor applies to user actors only. A token must still carry
// its own row, or a scoped token would silently widen to every
// driver the moment its issuer held the `driver` root.
const { actor } = await makeGroupedUser();
expect(await permService.check(actor, 'driver:puter-kvstore')).toBe(
true,
);
expect(
await permService.check(
scopedToken(actor, `tok-${uuidv4()}`),
'driver:puter-kvstore',
),
).toBe(false);
});
it('a scoped token resolves a permission it carries and its issuer holds', async () => {
const { row, actor } = await makeGroupedUser();
const permission = `zztest:tok-${uuidv4()}:ii:read`;
@@ -1778,53 +1731,3 @@ describe('PermissionService — scan paths', () => {
});
});
});
describe('PermissionService — default user permissions vs. group config', () => {
/** A user with no group membership, on an arbitrary server. */
const makeLooseActor = async (srv: PuterServer): Promise<Actor> => {
const username = `pdg${Math.random().toString(36).slice(2, 10)}`;
const u = await srv.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
requires_email_confirmation: false,
});
return {
user: {
id: u.id,
uuid: u.uuid,
username: u.username,
email: u.email ?? null,
},
};
};
it('grants the default permissions with no default group configured', async () => {
// Self-hosted deployments may run without default groups. The floor
// is not group-derived, so it applies regardless — a deployment that
// clears both no longer has every driver call fail closed.
const bare = await setupTestServer({
default_user_group: '',
default_temp_group: '',
} as never);
try {
const perms = bare.services
.permission as unknown as PermissionService;
const actor = await makeLooseActor(bare);
expect(await perms.check(actor, 'driver:puter-kvstore')).toBe(true);
expect(
await perms.check(
actor,
'service:puter-kvstore:ii:puter-kvstore',
),
).toBe(true);
// Still only the roots the floor names.
expect(await perms.check(actor, 'local-terminal:access')).toBe(
false,
);
} finally {
await bare.shutdown();
}
}, 60_000);
});
@@ -41,7 +41,7 @@ import {
// @ts-ignore — hardcoded-permissions.js is plain JS
import {
default_implicit_user_app_permissions,
default_user_permissions,
hardcoded_user_group_permissions,
implicit_user_app_permissions,
} from '../../data/hardcoded-permissions.js';
import { UserRow } from '../../stores/user/UserStore';
@@ -81,6 +81,16 @@ export class PermissionService extends PuterService {
private readonly rewriters: PermissionRewriter[] = [];
private readonly implicators: PermissionImplicator[] = [];
private readonly exploders: PermissionExploder[] = [];
/**
* System-issued grants registered at runtime by other services. Keyed by
* group UID, then by permission string. Merged with the imported
* `hardcoded_user_group_permissions.system` map during the hc-user-group
* scan.
*/
private readonly systemGrantsByGroupUid: Record<
string,
Record<string, unknown>
> = {};
// -- Extension hooks ----------------------------------------------
//
@@ -100,6 +110,38 @@ export class PermissionService extends PuterService {
this.exploders.push(exploder);
}
/**
* Grant a permission (as issued by `system`) to everyone — members of both
* the default user group and the default temp group.
*
* Call from an owning service's `onServerStart` (or later).
*/
registerSystemGrantForEveryone(
permission: string,
data: unknown = {},
): void {
const userGroup = this.config.default_user_group;
const tempGroup = this.config.default_temp_group;
if (userGroup) this.#addSystemGrant(userGroup, permission, data);
if (tempGroup) this.#addSystemGrant(tempGroup, permission, data);
}
/**
* Grant a permission (as issued by `system`) to non-temp users only —
* members of the default user group, but not the default temp group.
*/
registerSystemGrantForUsers(permission: string, data: unknown = {}): void {
const userGroup = this.config.default_user_group;
if (userGroup) this.#addSystemGrant(userGroup, permission, data);
}
#addSystemGrant(groupUid: string, permission: string, data: unknown): void {
if (!this.systemGrantsByGroupUid[groupUid]) {
this.systemGrantsByGroupUid[groupUid] = {};
}
this.systemGrantsByGroupUid[groupUid][permission] = data;
}
// -- Rewrite / explode (pure-ish helpers) ------------------------
async rewritePermission(permission: string): Promise<string> {
@@ -331,38 +373,6 @@ export class PermissionService extends PuterService {
}
options = exploded.flat();
// -- default user permissions --
// A group-independent floor every user actor holds, resolved in
// memory (see `default_user_permissions`). Derived actors are
// excluded: an app-under-user is gated by its own implicit grant map
// and an access token by its issuer, both of which recurse into a
// scan of the user actor and so still see this floor.
if (!actor.app && !actor.accessToken && actor.user?.id) {
const granted = options.find((option) =>
Object.prototype.hasOwnProperty.call(
default_user_permissions,
option,
),
);
if (granted !== undefined) {
reading.push({
$: 'option',
key: 'default-user-permission',
permission: granted,
source: 'implied',
by: 'default-user-permission',
data: (default_user_permissions as Record<string, unknown>)[
granted
],
holder_username: actor.user.username,
issuer_username: 'system',
});
reading.push({ $: 'time', value: Date.now() - startTs });
await this.#maybeCacheScan(cacheKey, reading);
return reading;
}
}
// -- shortcut implicators --
let shortCircuit = false;
for (const permission of options) {
@@ -399,6 +409,7 @@ export class PermissionService extends PuterService {
this.#scanNonShortcutImplicators(actor, options, reading),
this.#scanAccessToken(actor, options, reading),
this.#scanUserUser(actor, options, reading, workingState),
this.#scanHcUserGroupUser(actor, options, reading),
this.#scanUserGroup(actor, options, reading),
this.#scanUserAppImplied(actor, options, reading),
this.#scanUserApp(actor, options, reading),
@@ -516,6 +527,96 @@ export class PermissionService extends PuterService {
reading.push(...subReadings);
}
/**
* Resolve permissions that a persistent group's members inherit from an
* issuer (typically `system`) via the hardcoded map in
* `hardcoded-permissions.js`, merged with any runtime grants registered
* through `registerSystemGrantForEveryone` /
* `registerSystemGrantForUsers`.
*/
async #scanHcUserGroupUser(
actor: Actor,
options: string[],
reading: ReadingNode[],
): Promise<void> {
if (actor.app || actor.accessToken) return;
if (!actor.user?.id) return;
const memberGroups = await this.stores.group.listGroupsWithMember(
actor.user.id,
);
if (memberGroups.length === 0) return;
const groupByUid: Record<string, { id: number; uid: string }> = {};
for (const g of memberGroups) {
groupByUid[g.uid] = { id: g.id, uid: g.uid };
}
// Compose the effective issuer → group → permission → data map by
// merging the imported hardcoded data with runtime-registered system
// grants. Runtime grants are always attributed to the `system` issuer.
const hcMap = hardcoded_user_group_permissions as Record<
string,
Record<string, Record<string, unknown>>
>;
const hasRuntimeGrants =
Object.keys(this.systemGrantsByGroupUid).length > 0;
const byIssuer: Record<
string,
Record<string, Record<string, unknown>>
> = hasRuntimeGrants
? { ...hcMap, system: { ...(hcMap.system ?? {}) } }
: hcMap;
if (hasRuntimeGrants) {
for (const [gUid, perms] of Object.entries(
this.systemGrantsByGroupUid,
)) {
byIssuer.system[gUid] = {
...(byIssuer.system[gUid] ?? {}),
...perms,
};
}
}
for (const issuerUsername of Object.keys(byIssuer)) {
const issuerUser =
await this.stores.user.getByUsername(issuerUsername);
if (!issuerUser) continue;
const issuerActor = this.#userToActor(issuerUser);
const issuerGroups = byIssuer[issuerUsername];
for (const groupUid of Object.keys(issuerGroups)) {
if (!groupByUid[groupUid]) continue;
const issuerGroupPerms = issuerGroups[groupUid];
for (const permission of options) {
if (
!Object.prototype.hasOwnProperty.call(
issuerGroupPerms,
permission,
)
)
continue;
const issuerReading = await this.scan(
issuerActor,
permission,
);
reading.push({
$: 'path',
via: 'hc-user-group',
has_terminal: readingHasTerminal(issuerReading),
permission,
data: issuerGroupPerms[permission],
holder_username: actor.user.username,
issuer_username: issuerUsername,
reading: issuerReading,
group_id: groupByUid[groupUid].id,
});
}
}
}
}
async #scanUserGroup(
actor: Actor,
options: string[],
+7 -3
View File
@@ -298,7 +298,8 @@ export class SocketService extends PuterService {
// `{ auth: { ... } }`, not the query string. puter-js uses
// `io(url, { auth: { auth_token } })`.
const handshakeAuth = socket.handshake.auth as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const tokenRaw =
typeof handshakeAuth?.auth_token === 'string'
? handshakeAuth.auth_token
@@ -621,7 +622,8 @@ export class SocketService extends PuterService {
// their next poll of /cache/last-change-timestamp.
const originalSocketId = (
data.response as
{ original_client_socket_id?: string } | undefined
| { original_client_socket_id?: string }
| undefined
)?.original_client_socket_id;
await this.send({ room: userId }, 'cache.updated', {
timestamp,
@@ -635,7 +637,9 @@ export class SocketService extends PuterService {
#handleUploadProgress(data: UploadProgressPayload): void {
const meta = data.meta ?? {};
const userId = (meta.user_id ?? meta.userId) as
number | string | undefined;
| number
| string
| undefined;
if (!userId) {
console.warn('[socket] upload-progress missing user_id', { meta });
return;
+2 -3
View File
@@ -244,9 +244,8 @@ export const createTestUser = async (
requires_email_confirmation: false,
});
// Driver permissions no longer come from here (see
// `default_user_permissions`), but group-issued ACL grants do, and a
// verified signup is a member of the default user group.
// Base driver permissions (kv, notifications, …) are system grants on
// the default user group — membership is what a verified signup gets.
const { default_user_group } = await loadDefaultConfig();
if (default_user_group) {
await server.stores.group.addUsers(default_user_group, [opts.username]);
+3 -1
View File
@@ -1737,7 +1737,9 @@ input.myapps-group-name:focus {
color: #fff;
}
@media (hover: hover) {
.app-feedback-btn-primary:hover {
/* :not(:disabled) matches the base .app-feedback-btn hover rule's
specificity, so this later rule wins for primary buttons again. */
.app-feedback-btn-primary:hover:not(:disabled) {
filter: brightness(1.05);
background: linear-gradient(#34a5f8, #088ef0);
}