mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
908ec23eda | ||
|
|
d5ae5a0049 | ||
|
|
419d0aaa89 | ||
|
|
c3a0c28705 | ||
|
|
7ceb2090b7 |
No files matched your search
+39
-35
@@ -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,26 +51,30 @@ 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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
|
||||
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.
|
||||
|
||||
## 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)
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested remediation (if available)
|
||||
- Paypal id where we can send the bounty (if eligible)
|
||||
|
||||
We aim to acknowledge receipt within **72 hours** and provide a resolution timeline.
|
||||
|
||||
@@ -78,10 +82,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.
|
||||
|
||||
@@ -206,6 +206,84 @@ 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({
|
||||
|
||||
+74
-3
@@ -33,6 +33,68 @@ 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,
|
||||
@@ -74,8 +136,12 @@ export const handleWhoami = async (
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
|
||||
if (metadata) delete metadata.tmp_password;
|
||||
// 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 details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
@@ -84,7 +150,9 @@ export const handleWhoami = async (
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
phone: user.phone,
|
||||
// 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.
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
@@ -196,6 +264,9 @@ 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);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 60;
|
||||
const CURRENT_SCHEMA_VERSION = 61;
|
||||
const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
|
||||
|
||||
const sqliteConfig = (
|
||||
|
||||
@@ -94,6 +94,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[57, ['0062_blocked-app-origins.sql']],
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
[60, ['0065_app-feedback.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration
|
||||
-- 0065. Opt-in is the new `apps.feedback_enabled` column (developer-writable
|
||||
-- through the regular `puter.apps.update` path). Each row of `app_feedback`
|
||||
-- is one message a signed-in user submitted through the GUI feedback dialog;
|
||||
-- a copy is emailed to the app owner unless the per-app daily email cap
|
||||
-- suppressed it (`email_sent` records which). `app_uid` is denormalized
|
||||
-- alongside `app_id` so rows stay attributable after an app is deleted.
|
||||
-- `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- always runs first) and the table uses `CREATE TABLE IF NOT EXISTS`, so the
|
||||
-- directory can replay safely.
|
||||
|
||||
CALL _puter_add_col('apps', 'feedback_enabled', '`feedback_enabled` tinyint(1) DEFAULT ''0''');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_feedback` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`uid` CHAR(36) NOT NULL,
|
||||
`app_id` INT NOT NULL,
|
||||
`app_uid` CHAR(40) NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`message` TEXT NOT NULL,
|
||||
`source_env` VARCHAR(16) DEFAULT NULL,
|
||||
`source_origin` VARCHAR(2048) DEFAULT NULL,
|
||||
`email_sent` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_app_feedback_uid` (`uid`),
|
||||
KEY `idx_app_feedback_app_created` (`app_id`, `created_at`),
|
||||
KEY `idx_app_feedback_user_created` (`user_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration
|
||||
-- 0065 / MySQL mysql_mig_20. Opt-in is the new `apps.feedback_enabled` column
|
||||
-- (developer-writable through the regular `puter.apps.update` path). Each row
|
||||
-- of `app_feedback` is one message a signed-in user submitted through the GUI
|
||||
-- feedback dialog; a copy is emailed to the app owner unless the per-app
|
||||
-- daily email cap suppressed it (`email_sent` records which). `app_uid` is
|
||||
-- denormalized alongside `app_id` so rows stay attributable after an app is
|
||||
-- deleted. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE apps ADD COLUMN IF NOT EXISTS feedback_enabled boolean NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_feedback (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
uid CHAR(36) NOT NULL UNIQUE,
|
||||
app_id BIGINT NOT NULL,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
source_env VARCHAR(16) DEFAULT NULL,
|
||||
source_origin VARCHAR(2048) DEFAULT NULL,
|
||||
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created
|
||||
ON app_feedback (app_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created
|
||||
ON app_feedback (user_id, created_at);
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Opt-in is the new
|
||||
-- `apps.feedback_enabled` column (developer-writable through the regular
|
||||
-- `puter.apps.update` path; a dedicated column rather than a `metadata` key
|
||||
-- because Dev Center saves replace the whole metadata blob and would erase
|
||||
-- it). Each row of `app_feedback` is one message a signed-in user submitted
|
||||
-- through the GUI feedback dialog; a copy is emailed to the app owner unless
|
||||
-- the per-app daily email cap suppressed it (`email_sent` records which).
|
||||
-- `app_uid` is denormalized alongside `app_id` so rows stay attributable
|
||||
-- after an app is deleted (abuse forensics). `source_env` is 'app' (desktop
|
||||
-- dialog) or 'web' (puter.com popup opened from an external site);
|
||||
-- `source_origin` is the popup opener's browser-attested origin, null for
|
||||
-- desktop submissions. `created_at` is unix seconds. The (user_id,
|
||||
-- created_at) and (app_id, created_at) indexes serve the sliding-window
|
||||
-- rate-limit counts in AppFeedbackStore.
|
||||
|
||||
ALTER TABLE apps ADD COLUMN "feedback_enabled" tinyint(1) DEFAULT '0';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_feedback` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"uid" TEXT NOT NULL UNIQUE,
|
||||
"app_id" INTEGER NOT NULL,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"source_env" TEXT DEFAULT NULL, -- 'app' | 'web'
|
||||
"source_origin" TEXT DEFAULT NULL, -- attested opener origin (web popups)
|
||||
"email_sent" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created
|
||||
ON `app_feedback` (`app_id`, `created_at`);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created
|
||||
ON `app_feedback` (`user_id`, `created_at`);
|
||||
@@ -179,6 +179,29 @@ describe('EmailClient — template rendering', () => {
|
||||
expect(captured.html).toContain('<strong>424242</strong>');
|
||||
});
|
||||
|
||||
it('does not HTML-escape values in the plain-text subject header', async () => {
|
||||
const client = startClient();
|
||||
const captured: { subject?: string } = {};
|
||||
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
|
||||
captured.subject = options.subject;
|
||||
return null;
|
||||
});
|
||||
|
||||
await client.send('[email protected]', 'app-user-feedback', {
|
||||
owner_username: 'dev',
|
||||
sender_username: 'user',
|
||||
sender_email: null,
|
||||
app_title: "Bob's App & Games",
|
||||
app_name: 'bobs-app',
|
||||
app_link: 'https://puter.example/app/bobs-app',
|
||||
message: 'hi',
|
||||
});
|
||||
|
||||
// A subject is not HTML — entities would render literally in the
|
||||
// recipient's mail client.
|
||||
expect(captured.subject).toBe("New user feedback for Bob's App & Games");
|
||||
});
|
||||
|
||||
it('escapes html and converts newlines in nl2br values', async () => {
|
||||
const client = startClient();
|
||||
let html = '';
|
||||
|
||||
@@ -152,11 +152,16 @@ export class EmailClient extends PuterClient {
|
||||
|
||||
// -- Public API: sending ------------------------------------------
|
||||
|
||||
/** Render a template and send it to `to`. */
|
||||
/**
|
||||
* Render a template and send it to `to`. `options.replyTo` sets the
|
||||
* Reply-To header (e.g. so a recipient can respond to the originator of the
|
||||
* message rather than the no-reply From address).
|
||||
*/
|
||||
async send<T extends EmailTemplateName>(
|
||||
to: string,
|
||||
template: T,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { replyTo?: string } = {},
|
||||
): Promise<void> {
|
||||
const compiled = this.compiledTemplates[template];
|
||||
if (!compiled) {
|
||||
@@ -168,6 +173,7 @@ export class EmailClient extends PuterClient {
|
||||
to,
|
||||
subject: compiled.subject(values),
|
||||
html: compiled.html(values),
|
||||
...(options.replyTo ? { replyTo: options.replyTo } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,7 +291,14 @@ export class EmailClient extends PuterClient {
|
||||
private compileTemplates(): void {
|
||||
for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) {
|
||||
this.compiledTemplates[name as EmailTemplateName] = {
|
||||
subject: handlebars.compile(template.subject),
|
||||
// Subjects are plain-text headers: HTML-escaping would put
|
||||
// literal entities in front of the recipient (& etc.).
|
||||
// Header safety is handled elsewhere — the transport encodes
|
||||
// newlines, and free-form values (e.g. app_title) collapse
|
||||
// whitespace upstream.
|
||||
subject: handlebars.compile(template.subject, {
|
||||
noEscape: true,
|
||||
}),
|
||||
html: handlebars.compile(dedent(template.html)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,25 @@ Please update <a href="https://puter.com/app/{{app_name}}">{{app_title}}</a>.
|
||||
<blockquote>{{nl2br message}}</blockquote>
|
||||
<p>Best,<br />
|
||||
The Puter Team
|
||||
</p>
|
||||
`,
|
||||
},
|
||||
'app-user-feedback': {
|
||||
subject: 'New user feedback for {{app_title}}',
|
||||
html: `
|
||||
<p>Hi{{#if owner_username}} {{owner_username}}{{/if}},</p>
|
||||
<p>
|
||||
<strong>{{sender_username}}</strong>{{#if sender_email}} (<a href="mailto:{{sender_email}}">{{sender_email}}</a>){{/if}} sent feedback about <a href="{{app_link}}">{{app_title}}</a>:
|
||||
</p>
|
||||
<blockquote>{{{nl2br message}}}</blockquote>
|
||||
{{#if sender_email}}<p>Just reply to this email to respond to them directly.</p>{{/if}}
|
||||
<p>
|
||||
You're receiving this because feedback is enabled for
|
||||
<a href="{{app_link}}">{{app_title}}</a> — manage it in the
|
||||
<a href="{{dev_center_link}}">Dev Center</a> under your app's settings.
|
||||
</p>
|
||||
<p>Best,<br />
|
||||
The Puter Team
|
||||
</p>
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -540,11 +540,10 @@ export type EventKey = keyof EventMap & string;
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
|
||||
@@ -1101,8 +1101,7 @@ 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,6 +212,30 @@ 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,
|
||||
@@ -338,8 +362,7 @@ 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 reaches the permission scan (403 forbidden — no
|
||||
// perms granted) instead of the credential-shape rejection.
|
||||
// session actor must get past the credential-shape gate.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
@@ -349,18 +372,8 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
});
|
||||
|
||||
it('admits app and access-token actors on the AI drivers (they fail later on permission, not credential shape)', async () => {
|
||||
@@ -385,29 +398,15 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
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',
|
||||
});
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
}
|
||||
});
|
||||
|
||||
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 (they fail later on permission here, not on
|
||||
// credential shape).
|
||||
// AI access rather than be rejected for their credential shape.
|
||||
const base = await makeUserActor();
|
||||
const actor: Actor = {
|
||||
...base,
|
||||
@@ -421,24 +420,13 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
});
|
||||
|
||||
it('still allows bare session actors on drivers without the noUserSession flag', async () => {
|
||||
// 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.
|
||||
// puter-kvstore doesn't set the flag, so the session actor must get
|
||||
// past the credential-shape gate.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
@@ -448,24 +436,24 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
});
|
||||
|
||||
it('rejects with 403 when the actor lacks the service permission', async () => {
|
||||
// A fresh user has no service:* perms → permission.check returns
|
||||
// false → 403.
|
||||
const actor = await makeUserActor();
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
@@ -474,15 +462,8 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err).toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('returns the wrapped {success, result, service} envelope on a successful call', async () => {
|
||||
|
||||
@@ -297,8 +297,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { AppFeedbackService } from '../../services/feedback/AppFeedbackService.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
|
||||
// Boots one real PuterServer (in-memory sqlite + mocked externals) and
|
||||
// registers AppFeedbackController's decorated routes onto a fresh
|
||||
// PuterRouter. Tests drive the captured handlers with stub req/res; the
|
||||
// stores/services underneath are the live wired ones, so rows land in the
|
||||
// real `app_feedback` table.
|
||||
|
||||
let server: PuterServer;
|
||||
let router: PuterRouter;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
router = new PuterRouter();
|
||||
server.controllers.appFeedback.registerRoutes(router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const username = `fdbk-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: refreshed.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: refreshed.id,
|
||||
uuid: refreshed.uuid,
|
||||
username: refreshed.username,
|
||||
email: refreshed.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const makeApp = async (
|
||||
ownerUserId: number,
|
||||
opts: { feedbackEnabled?: boolean; indexUrl?: string; name?: string } = {},
|
||||
) => {
|
||||
const name =
|
||||
opts.name ?? `fdbk-app-${Math.random().toString(36).slice(2, 10)}`;
|
||||
return await server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: `Feedback Test ${name}`,
|
||||
index_url: opts.indexUrl ?? `https://${name}.example.com`,
|
||||
...(opts.feedbackEnabled ? { feedback_enabled: 1 } : {}),
|
||||
},
|
||||
{ ownerUserId },
|
||||
);
|
||||
};
|
||||
|
||||
// Feedback is only offered when the deployment can deliver it (email
|
||||
// transport configured); most tests want that baseline without asserting
|
||||
// anything about the mail itself.
|
||||
const mockEmailConfigured = () =>
|
||||
vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue(
|
||||
true,
|
||||
);
|
||||
|
||||
const confirmOwnerEmail = async (userId: number) => {
|
||||
await server.clients.db.write(
|
||||
'UPDATE `user` SET `email_confirmed` = ? WHERE `id` = ?',
|
||||
[server.clients.db.booleanValue(true), userId],
|
||||
);
|
||||
const user = await server.stores.user.getById(userId);
|
||||
if (user) await server.stores.user.invalidate(user);
|
||||
};
|
||||
|
||||
const makeReq = (init: {
|
||||
body?: unknown;
|
||||
actor?: Actor;
|
||||
query?: Record<string, unknown>;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? {},
|
||||
query: init.query ?? {},
|
||||
headers: {},
|
||||
actor: init.actor,
|
||||
} as unknown as Request;
|
||||
};
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: { statusCode: number; body: unknown } = {
|
||||
statusCode: 200,
|
||||
body: undefined,
|
||||
};
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
captured.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
const findRoute = (method: string, path: string) => {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
);
|
||||
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
|
||||
return route;
|
||||
};
|
||||
|
||||
const callRoute = async (
|
||||
method: string,
|
||||
path: string,
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => {
|
||||
const handler: RequestHandler = findRoute(method, path).handler;
|
||||
await handler(req, res, () => {
|
||||
throw new Error('handler called next() unexpectedly');
|
||||
});
|
||||
};
|
||||
|
||||
const submit = (actor: Actor, body: unknown) => {
|
||||
const { res, captured } = makeRes();
|
||||
return callRoute('post', '/', makeReq({ body, actor }), res).then(
|
||||
() => captured,
|
||||
);
|
||||
};
|
||||
|
||||
// ── Route gates ─────────────────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackController route options', () => {
|
||||
it('rejects app actors and cross-origin pages on submit', () => {
|
||||
const { options } = findRoute('post', '/');
|
||||
// requireUserActor is what makes feedback impossible to submit
|
||||
// programmatically with an app token; guiOriginOnly keeps
|
||||
// cross-origin browser pages out even with a leaked user token.
|
||||
expect(options.requireUserActor).toBe(true);
|
||||
expect(options.guiOriginOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('stacks a per-user budget with a per-IP backstop', () => {
|
||||
const { options } = findRoute('post', '/');
|
||||
const limits = options.rateLimit;
|
||||
expect(Array.isArray(limits)).toBe(true);
|
||||
const keys = (limits as Array<{ key?: unknown }>).map((l) => l.key);
|
||||
expect(keys).toContain('user');
|
||||
expect(keys).toContain('ip');
|
||||
});
|
||||
|
||||
it('requires a user actor on the target pre-flight too', () => {
|
||||
const { options } = findRoute('get', '/target');
|
||||
expect(options.requireUserActor).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /app-feedback/target ────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackController GET /target', () => {
|
||||
it('throws 400 when neither or both of app/origin are given', async () => {
|
||||
const { actor } = await makeUser();
|
||||
for (const query of [
|
||||
{},
|
||||
{ app: 'x', origin: 'https://x.example.com' },
|
||||
]) {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute('get', '/target', makeReq({ query, actor }), res),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports enabled:false for an unknown app', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: 'no-such-app-xyz' }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({ enabled: false, app: null });
|
||||
});
|
||||
|
||||
it('reports enabled:false for an app that has not opted in', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId);
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: app.name }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toMatchObject({ enabled: false });
|
||||
});
|
||||
|
||||
it('reports enabled:true with canonical title/name for an opted-in app', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: app.uid }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
enabled: true,
|
||||
app: { name: app.name, title: app.title },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an opted-in app whose name starts with "app-"', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const name = `app-fdbk-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true, name });
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: name }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
enabled: true,
|
||||
app: { name: app.name, title: app.title },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an origin to the app whose index_url it matches', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const origin = new URL(app.index_url).origin;
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { origin }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
enabled: true,
|
||||
app: { name: app.name, title: app.title },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports enabled:false when the email transport is unconfigured', async () => {
|
||||
// No mockEmailConfigured(): this is the self-hosted no-SMTP default.
|
||||
// Feedback that can never be delivered must not be solicited.
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: app.name }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toMatchObject({ enabled: false });
|
||||
});
|
||||
|
||||
it('reports enabled:false for an origin with no registered app', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({
|
||||
query: { origin: 'https://nobody-registered.example.com' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({ enabled: false, app: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /app-feedback ──────────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackController POST /', () => {
|
||||
it('throws 400 when message is missing or not a string', async () => {
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
for (const message of [undefined, 12345, '']) {
|
||||
await expect(
|
||||
submit(actor, { app: app.name, message }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws 400 when both app and origin are given', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, {
|
||||
app: 'x',
|
||||
origin: 'https://x.example.com',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when the origin exceeds the stored column size', async () => {
|
||||
// source_origin is VARCHAR(2048) on MySQL/Postgres; a longer origin
|
||||
// must be rejected up front, not fail (or silently truncate) at the
|
||||
// INSERT after passing every other validation.
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const origin = `${new URL(app.index_url).origin}/${'x'.repeat(2500)}`;
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { origin, message: 'hi' }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 403 feedback_not_enabled when the app has not opted in', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId);
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { app: app.name, message: 'hi there' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'feedback_not_enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 403 for an unknown app and an unknown origin alike', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { app: 'no-such-app-xyz', message: 'hi' }),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
await expect(
|
||||
submit(actor, {
|
||||
origin: 'https://nobody-registered.example.com',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 feedback_not_enabled when the email transport is unconfigured', async () => {
|
||||
// No mockEmailConfigured(): the opted-in app must still refuse — a
|
||||
// stored row nothing can read, sold to the sender as delivered, is
|
||||
// worse than an honest refusal.
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { app: app.name, message: 'into the void' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'feedback_not_enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 400 when the message exceeds the length limit', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, {
|
||||
app: app.name,
|
||||
message: 'x'.repeat(
|
||||
AppFeedbackService.MESSAGE_MAX_LENGTH + 1,
|
||||
),
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('stores a normalized row and responds with an empty object', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor, userId } = await makeUser();
|
||||
const captured = await submit(actor, {
|
||||
app: app.uid,
|
||||
message: ' Great\r\napp! | ||||