Security review – October 2026
Scope: the whole server (server/, 863 routes: 153 public, 65 user, 89 admin, 556 sub-account), the public
pages, the MCP endpoint, the mobile API, the Docker setup and dependencies – before the open-source release.
Method: automated harnesses that enumerate the router (tenant isolation, stored XSS, secret leaks) plus manual
review of authentication, CSRF, SSRF, file handling, secrets, webhooks, DoS and supply chain.
Design and protections: security.md.
Summary
Section titled “Summary”| Severity | Found | Fixed |
|---|---|---|
| Critical | 0 | 0 |
| High | 4 | 4 |
| Medium | 10 | 10 |
| Low | 18 | 18 |
| Info / hardening | 6 | 5 (+1 documented) |
New test files: tests/tenant-isolation.test.ts, tests/xss.test.ts, tests/secret-leaks.test.ts,
tests/security.test.ts, tests/privacy.test.ts (shared fixtures: tests/security-fixtures.ts).
Findings
Section titled “Findings”| ID | Severity | Location | Finding | Fix | Test |
|---|---|---|---|---|---|
| H-1 | High | modules/mcp.ts (agency-key guard) |
Agency API keys could download full database backups (/api/admin/backup, /api/admin/backups/*) with password hashes and 2FA secrets, and pair mobile devices / start OAuth / manage push as the admin. |
Keys are refused on backups, OAuth, push and mobile pairing (besides users, me, keys, auth). | tenant-isolation › agency keys manage data… |
| H-2 | High | core/app.ts clientIp |
Behind a proxy the left-most X-Forwarded-For entry was trusted – any client could pick its address and dodge per-IP rate limits and login lockouts. |
Right-most entry added by our proxies (PGHL_PROXY_HOPS). |
security › limits public writes … X-Forwarded-For |
| H-3 | High | services/messaging.ts (HTTP SMS gateway) |
Plain fetch to the sub-account’s gateway URL, response body echoed into the message error: SSRF with response reading (cloud metadata, internal services) for any client login with settings access or a sub-account key. |
safeFetch with size and time limits. |
security › an HTTP SMS gateway cannot point at internal addresses |
| H-4 | High | services/net.ts isPrivateIp |
IPv4-mapped IPv6 literals in hex (http://[::ffff:7f00:1]/) passed the SSRF guard → webhooks, REST hooks, workflow HTTP steps and uptime checks could reach localhost/metadata (blind, status reflected). |
Node BlockList over all private/transition ranges (mapped addresses matched as IPv4, NAT64, 6to4, Teredo, site-local…). |
security › treats every spelling of internal addresses as private; refuses IPv4-mapped literals |
| M-1 | Medium | modules/calendar-sync.ts |
iCal import used a host-name blocklist (bypass via DNS names, mapped IPv6, redirects) and plain fetch; events from the response are stored. |
safeFetch (resolved-IP check, redirect re-check, 5 MB cap). |
security › iCal import URLs go through the guard |
| M-2 | Medium | modules/docs.ts POST /api/docs |
Documents are unique by URL agency-wide; the upsert ran with force and moved another sub-account’s document (and returned it) to whoever posted the URL. |
Refused (409) when the URL belongs to another project or to the agency inbox – only an admin session may move it; response scoped. | tenant-isolation › creating or upserting by url/email/slug…; security › a document link … is not moved |
| M-3 | Medium | settings JSON, users.totp_secret, webhooks.secret, rest_hooks.secret |
SMTP passwords, Twilio/SMS tokens, the inbound key, 2FA secrets and webhook signing secrets were stored in plain text. | Sealed (AES-256-GCM), self-healing on read; key rotation tool npm run rotate-secret with data key + previous keys. |
security › secrets at rest (5 tests), key rotation |
| M-4 | Medium | modules/core.ts login |
Wrong 2FA codes only counted per IP – a distributed guess of 6-digit codes (after a password leak) was not rate limited per account. | Wrong codes count towards the account lockout and are audited. | security › wrong 2FA codes from many addresses lock the account |
| M-5 | Medium | core/csv.ts, services/report-engine.ts |
CSV exports wrote cell values verbatim: a lead named =HYPERLINK(…) from a public form became a formula in Excel (CSV injection). |
Cells starting with = + - @ TAB CR get a leading ' (plain numbers untouched). |
security › CSV exports neutralise spreadsheet formulas |
| M-6 | Medium | services/markdown.ts, services/community.ts |
Link/image patterns were quadratic ([a]( repeated: 50 KB ≈ 0.6 s) – community members could slow every page view of a feed. |
Linear patterns (one level of (…) in URLs still works). |
security › markdown links/images are linear on hostile input |
| M-7 | Medium | core/app.ts request handling |
Bodies (up to 10 MB) were read before authentication on every route; public routes accepted 10 MB. | Authentication on headers first; 1 MB public limit with registered exceptions (app.services.bodyLimits); /mcp large bodies only with a valid key. |
security › caps bodies …; MCP takes large bodies only with a valid key; mobile › APK upload |
| M-8 | Medium | public write routes | Booking, reviews, surveys, contract signing, help-center feedback etc. had no limit besides the global 900 requests/min. | 120 public writes/min per address (provider webhooks and MCP excluded). | security › limits public writes per address… |
| M-9 | Medium | modules/contacts.ts PATCH /api/custom-fields/:id |
The update was scoped, but the response read the field by id only – any sub-account could read another one’s custom field (IDOR). | Response scoped, 404 for foreign ids. | tenant-isolation (member/key); security › PATCH of another sub-account’s custom field |
| M-10 | Medium | services/snapshots.ts, modules/calendar-sync.ts |
Snapshots (agency templates, SaaS sign-ups, funnel copy) carried the calendar’s private feed token and imported iCal URLs into other sub-accounts; /cal/<token>.ics then served the source calendar’s appointments (names, e-mails, phones) to the copy’s owner, and downloaded snapshot files leaked private Google/Outlook iCal URLs. |
Feed token, iCal URLs and status are stripped on export and on apply; an ambiguous token serves nothing and is replaced when its owner opens the feed. | security › snapshots never carry a calendar feed token… |
| L-1 | Low | modules/reports.ts, objects-reports.ts |
/api/reports/sources listed custom objects and property names/options of every sub-account. |
Sources/fields carry their sub-accounts and are filtered per caller (scopeSources). |
tenant-isolation (member/key) |
| L-2 | Low | core/util.ts |
scrypt N=2^14, p=1 (below OWASP); synchronous hashing blocked the event loop. | N=2^15, r=8, p=3, async with concurrency cap, upgrade at login. | security › hashes passwords with scrypt … |
| L-3 | Low | login | Unknown e-mails skipped scrypt (timing reveals existing accounts). | Dummy verification. | security › hashes passwords … (verifyPasswordAsync with no hash) |
| L-4 | Low | startSession |
A session id the browser brought to the login stayed valid (fixation, shared devices). | Deleted at login. | security › a session id brought to the login never survives it |
| L-5 | Low | PATCH /api/users/:id |
A password set by the admin left the user’s other sessions alive. | Other sessions revoked (devices already were). | security › a password set by the admin signs that person out everywhere |
| L-6 | Low | POST /api/me/password |
Current-password check not throttled (stolen session could guess it). | Account-style lockout. | security › guessing the current password … is throttled |
| L-7 | Low | conversations, tickets, workflows, video-recycle, portals | Inbound SMS/mail keys, support-mail key, workflow webhook keys, signed clip URLs and portal CSRF tokens compared with !==. |
safeEqual (constant time). |
security › constant-time secret comparison |
| L-8 | Low | modules/attribution.ts safeBack |
Consent redirect allowed control characters – /\t/evil.example becomes //evil.example in browsers (open redirect). |
Control characters refused. | security › the consent redirect stays on the site |
| L-9 | Low | modules/core.ts uploads, modules/forms.ts |
Stored extension came from the client’s file name (evil.html); form uploads were not checked by content. |
Shared magic-byte check (core/files.ts), extension from the checked type. |
security › stores uploads …; form uploads must match their type |
| L-10 | Low | core/app.ts serveStatic |
Prefix check let a sibling folder with the same name prefix through; a malformed % gave a 500. |
Path must be inside the web folder (root + sep); 400 on bad encoding. |
security › sends HSTS … (sibling folder) |
| L-11 | Low | list endpoints | limit=-1 became LIMIT -1 (= unlimited) on several lists incl. public chat history and help-center search. |
clampLimit (1…max). |
security › clamps page sizes |
| L-12 | Low | services/net.ts |
Redirects kept Authorization/Cookie when leaving the origin; only an idle timeout (slow-drip responses). |
Credentials dropped cross-origin; hard deadline. | security › does not forward credentials when a redirect leaves the origin |
| L-13 | Low | core/app.ts |
No Permissions-Policy, no COOP on the app, server pages without any CSP, Node default timeouts (slowloris). |
Headers added, baseline CSP, header/request/keep-alive timeouts. | security › sends HSTS …; public pages … get a baseline CSP |
| L-14 | Low | outgoing webhooks / REST hooks | Signature sha256(secret + body) is a secret-prefix construction (length-extension prone). |
Additional x-pghl-signature-256: sha256=<HMAC>; old header kept for existing receivers. |
security › keeps webhook signing secrets sealed and signs with HMAC-SHA256 |
| L-15 | Low | services/messaging.ts |
Mail header values relied on nodemailer to drop line breaks. | oneLine() for subject, to, reply-to, from name. |
security › email headers never carry line breaks |
| L-16 | Low | services/route-call.ts |
In-process calls could switch to another sub-account without a membership check (no exploitable caller found). | Same membership check as x-location-id. |
security › callRoute refuses to switch … |
| L-17 | Low | modules/video-recycle.ts |
Autopilot stored a foreign website id as blog target (later calls were scoped, so no data access). | Must be a website of the sub-account. | tenant-isolation (member/key) |
| L-18 | Low | maskSettings |
The inbound webhook key was readable via GET /api/location by members without the settings area. |
Masked like other secrets. | security › stores SMTP/SMS credentials … sealed |
| I-1 | Info | security module | Exports, downloads and backups were not audited; rights changes had no detail. | app.afterRead hook → export:* entries; users.rights.update with changed fields; lockouts and 2FA failures audited. |
privacy › exports …; logs rights changes |
| I-2 | Info | contacts | No GDPR access/erasure across modules (form files were never deleted, tickets and partner data stayed). | modules/privacy.ts: export/erase per contact and agency-wide by e-mail, UI buttons, MCP tools, hooks. |
privacy.test.ts |
| I-3 | Info | Dockerfile, compose.yaml, deploy/hostinger.compose.yaml |
Container ran as root; the Hostinger variant kept the git token in .git/config on the volume. |
USER node, cap_drop: [ALL], no-new-privileges; remote removed after clone. |
build |
| I-4 | Info | core/db.ts |
Deleted rows stayed readable in free pages of the database file. | secure_delete = FAST, trusted_schema = OFF. |
full suite |
| I-5 | Info | objects-reports.ts |
A deferred report-source rebuild crashed when the app closed right after start (CLI, tests). | Guarded. | security › key rotation |
| I-6 | Info | dependencies | npm audit: 2 moderate, dev-only (vitest/@vitest/mocker, GHSA-82fw-gwwq-j7x9 – file read through the Vitest browser/UI server). Production: 0. Lockfile: 260 packages, all with integrity hashes from registry.npmjs.org. |
Documented; needs the vitest 5 major upgrade (no new installs in this change). Never expose a Vitest UI/browser server. | – |
Checked without findings
Section titled “Checked without findings”- SQL: all queries parameterised; identifiers interpolated only from code or validated keys (
KEY_RE, allow-lists). - XSS: 5,000+ public page renders over 27 page types with a stored payload – all escaped; funnel/blog pages
are sandboxed (
CSP: sandbox, noallow-same-origin); admin previews of owner HTML run in sandboxed iframes. - Webhooks: Stripe (5-minute tolerance), Twilio, Meta (unsigned refused), Todoist, OpenShorts, inbound HMAC – all verified with constant-time comparison.
- Portals: magic links (32 bytes, 30 minutes, single use),
Origincheck plus per-session form token, strict CSP,X-Frame-Options: DENY;next/backparameters restricted to portal paths. - MCP: every tool goes through the REST API with the caller’s key (same rights, guards and audit); the isolation harness found no tool returning another sub-account’s data.
- Mobile tokens: only
/api/mobile/*, own rate limit, revoked with password changes. - CORS, session cookie flags, invite links,
cspExtra(only code-defined https/wss origins). - File serving: uploads, form files, community images, lesson downloads, backups and academy docs resolve names through the database or strict patterns.
Follow-ups (not in this change)
Section titled “Follow-ups (not in this change)”- Upgrade Vitest to 5.x (dev-only advisory).
- Optional
PGHL_SETUP_TOKENfor the first-run setup page (today: bootstrap the admin via environment). - Nonce-based CSP for the remaining server pages with inline scripts (booking, forms, invoices).
- Pin GitHub Actions by commit SHA once the CI workflows land (open-source branch).
Follow-up review: versions 0.0.37–0.0.40
Section titled “Follow-up review: versions 0.0.37–0.0.40”Scope: everything added since the review above – record-level access and sales (sequences, quotes, duplicates), the hosted cloud mode,
Rechnung 2.0, the consent ledger, forms in depth, the website builder 2.0, courses with hosted video, telephony (IVR, queues, power dialer,
call intelligence), the 24 importers, the Cognee memory, the community extras and the iOS web app. Method: reading the new modules like an
attacker (what can a restricted person, a tenant, the operator or an anonymous visitor reach that the author did not think of), then
extending the existing harnesses – the leak harness of tests/record-access-leaks.test.ts got seeds for the data that hangs on a contact in
the conversation area – and one regression test per finding in tests/sec-review-access.test.ts, tests/sec-review-cloud.test.ts and
tests/sec-review-public.test.ts (each fails on the code before the fix). Severity assumes the feature is switched on (record access, cloud mode).
| ID | Severity | Location | Finding and exploit sketch | Fix | Test |
|---|---|---|---|---|---|
| R-1 | High | modules/dialer.ts, modules/record-access.ts |
The power dialer handed out contacts the rules hide. A person limited to their own contacts creates a call list from a tag or smart list (the list is built server-side, so the “named records” check never sees the contacts) and presses Nächster Kontakt: the answer carries name, phone, e-mail, tags, the last three notes and the last calls of any contact. The entries, callbacks and the list endpoint listed everyone too. | The dialer asks app.services.recordAccess.contactFilter before it picks an entry; lists, entries and callbacks drop what the person may not open; entry routes answer 404. |
access › the power dialer neither lists nor hands out … |
| R-2 | High | modules/telephony.ts, call-intel.ts, voice-ai.ts, browser-phone.ts |
Call log and conversation intelligence were outside the rules. GET /api/calls (also ?q= over voicemail transcripts), /api/calls/:id, /recording (the audio), /notes, /api/call-intel* (full transcripts, summaries, objections, scores), /api/voice-ai/calls* and /api/phone/calls/:id/* answered for every call of the sub-account to anybody with the Konversationen area. Calls are found by id or Twilio sid. |
CHILD_ROUTES in modules/record-access.ts now covers these paths (id and sid) and CONTACT_LISTS filters the lists; a hidden phone field also hides the call’s numbers. |
access › the call log …; a call cannot be opened …; call intelligence …; the AI phone agent’s sessions … |
| R-3 | Medium | modules/email-import.ts, channels.ts, meta-channels.ts, mailbox-mail.ts, teaminbox.ts |
Drafts of all threads (/api/conversations/drafts: contact, subject, text), media files of a message (`/api/messages/channels |
meta/media/:id), mailbox attachments (/api/mail/attachments/:messageId/:index) and **POST /api/inbox/bulk`** (assign, close, snooze by conversation id) ignored the rules: a restricted person could read drafts and files of, and change, conversations they cannot open. |
Media and attachments are child routes of their conversation; drafts are filtered per conversation and contact; the inbox bulk route filters its ids like the other bulk routes. |
| R-4 | Medium | modules/mk-segments.ts, campaigns.ts, services/mobile-read.ts, record-access.ts |
Segment members, campaign recipients, the phone app’s call list (/api/mobile/calls) and reports on the Anrufe source (grouped by contact) listed names, e-mails and numbers of contacts outside the person’s view. The extended leak harness found /api/mobile/calls by itself. |
List filters, a contact clause in the app’s call query and a calls entry in the report row scope. |
access › segment members and campaign recipients …; the phone app’s call list …; reports on the call log … |
| R-5 | Medium | modules/cognee.ts |
The knowledge memory holds notes, contacts and conversations as text; “ask the memory” (also the MCP tool ghl_memory_recall and the in-app agent) only checked the member’s areas, so a person who sees only their own contacts could ask about every note, contact and conversation of the sub-account. |
For a narrowed person the recall refuses contacts, notes and conversations (wiki, tickets, courses and remembered notes stay); /api/cognee/info stops offering them. |
access › the Cognee memory |
| C-1 | High | modules/reports.ts |
The operator read every customer’s data through reports. POST /api/reports/run with scope: "all" as an admin ran over all sub-accounts – contacts, invoices, deals, grouped by contact name – without the customer’s support consent (the consent guard only watched the sub-account of the request). Same for boards that use the report engine. |
Admin report scopes (run, sources) skip customers not open for support (hiddenLocations). |
cloud › reports across all sub-accounts … |
| C-2 | Medium | modules/security.ts |
GET /api/admin/audit listed the audit trail of every customer (summaries carry names of contacts, invoices, pages). |
Entries of customers not open for support are left out. | cloud › the operator’s audit log … |
| C-3 | Medium | modules/core.ts (users), modules/cloud.ts |
User management was a back door to a customer’s account. An operator admin could set a customer login’s password (PATCH /api/users/:id), reset its 2FA or passkeys, sign it out, delete it, or create a login of their own with the customer’s sub-account in locations – opening the account without the consent. GET /api/users listed all customers’ people. |
The cloud guard refuses these writes for customers’ logins and sub-accounts without consent; the list leaves the customers’ people out. | cloud › user management … |
| C-4 | Medium | modules/funnels.ts, tickets.ts, agency.ts, cloud.ts |
A site or help center could claim somebody else’s login host. The check for a site’s custom domain looked only at other sites; a tenant could point login.client.example, the agency’s white-label domain or <other-customer>.<cloud domain> at their own page. A host decides which pages the server serves, so the claimed login address showed the claimer’s page (phishing with a valid certificate – the TLS “ask” endpoint approves every claimed host) and the real login stopped working. |
One check, app.services.domainTaken: the app’s own address, agency and client login domains, sites, help centers, and (cloud) the service’s domain and all its sub-domains for everybody but the operator. |
cloud › a site or help center cannot take over a login domain … |
| C-5 | Medium | modules/cloud.ts, services/course-video.ts, community-posts-ext.ts |
The storage fair-use meter counted only media uploads. Course videos (2 GB each, unlimited number), community videos, call audio and meeting recordings were free: a trial account without a card could fill the shared disk. | storageUsedBytes counts all of them; the uploads ask storageGate when they start (course and community video) or in the guard (raw audio). |
cloud › the storage fair-use meter … |
| P-1 | Low | modules/telephony.ts verifyTwilio |
The Twilio signature covers the URL and the form fields. A request with another body type was verified as “URL only” while the handlers read the body – one signed request without fields (e.g. a status callback seen in a log) paired with a JSON body of the attacker’s choosing would have been accepted. | Only form bodies are accepted. | public › Twilio webhooks only take forms |
| P-2 | Low | modules/agent-bridge.ts |
The 8-character pairing code of the local bridge (it hands out a device token) was drawn with Math.random. |
crypto.randomInt. |
public › device pairing … |
| P-3 | Low | modules/telephony-dni.ts |
A pool that shares numbers when all are busy wrote a session row for every anonymous visit and read all of them on every visit: a flood from a few addresses grows the table and slows every page with the number snippet. | At most 5,000 running sessions per pool get a row; the rest still get a number. | public › a pool that shares its numbers … |
| P-4 | Low | services/community-posts-ext.ts |
A community member could start any number of 500 MB video uploads in sequence (only open uploads were counted); unattached ones live a day. | Three videos’ worth per member and day; the team is not limited. | access › community members and their videos |
Open findings and limits
Section titled “Open findings and limits”| ID | Severity | Finding | Status |
|---|---|---|---|
| O-1 | Low | Sign-up pre-hijack (cloud-signup.ts): the password is chosen on the form before the address is proven. Somebody who enters a stranger’s address and their own password gets an account for that stranger the moment the stranger clicks the verification link in the (unsolicited) mail. |
Fixed after 0.0.41 – see follow-up fixes. |
| O-2 | Low | Public forms, booking and chat update an existing contact by e-mail or phone (upsertContact): a visitor who enters a known phone number with their own e-mail address replaces the stored e-mail. This is how the CRM deduplicates; the owner sees the change in the timeline. |
Fixed after 0.0.41 – see follow-up fixes. |
| O-3 | Info | Counters and aggregates (call statistics, counts in lists, reports on sources outside the five record types) are not narrowed by record access, and automations, keys and MCP connections see everything by design (sales.md). A restricted person with the Automationen area can build an automation that reads more than they can see. | Documented; give such people no automation area. |
| O-4 | Info | The operator runs the server and the database: the support consent covers the product surface (UI, API, MCP, reports, audit log, user management), not the file system. | Said in the AVV text and cloud.md. |
Follow-up fixes (after 0.0.41)
Section titled “Follow-up fixes (after 0.0.41)”The two open low findings and two robustness items from the code-health review, each with a regression test that fails on the code
before the fix (tests/sec-review-signup.test.ts in cloud mode, tests/sec-review-followup.test.ts).
| ID | Location | Fix | Test |
|---|---|---|---|
| O-1 | modules/cloud-signup.ts, services/cloud-pages.ts |
No account and no password before the address is proven. The form’s password only counts when the verification link is opened in the browser that sent the form (a cookie set on submit – set for known and unknown addresses alike, after the same hashing work, so it is no oracle); in any other browser the link asks for the password and the form’s one is discarded. The link (24 h) is claimed by the first confirmation with a single UPDATE … WHERE verified_at IS NULL; afterwards it only continues in the browser that confirmed. The confirmation – and a Google sign-up, whose address Google proved – closes every other open sign-up for the address (status superseded, password, link and browser binding cleared), so neither an earlier password nor an earlier browser leads into the account. Provisioning refuses a sign-up without verified_at, also when a completed Checkout names it. |
signup › a password typed into the form by somebody else never becomes the login; … a later verification … ends the earlier sign-ups; … no oracle; never provisions … not verified; Google proves the address too |
| O-2 | services/contacts.ts (upsertContact(…, { publicInput: true })), callers in forms.ts, wfx-triggers.ts (abandoned forms), calendars.ts, teamcal-book.ts, livechat.ts, ai-assistant.ts, voice-ai.ts (booking), webinar-register.ts, stripe.ts, ads-leads.ts |
Unauthenticated input never changes a stored e-mail address or phone number (an empty one is filled). A phone number only finds a contact when the input has no e-mail address: known number + another address creates a new contact (timeline note, paired by the duplicate assistant) – the dedupe logic already treats a shared number as “probably the same person”, and the booking confirmation then reaches the person who booked. A differing number on a contact found by its e-mail address is noted as an unverified alternative on the timeline. The chat widget shows a visitor only the messages from the visitor’s own start, and the chat bot answers from that chat and the name only (no e-mails or SMS of the contact, no stored numbers, address or deals), so a known address typed into the widget no longer reveals earlier conversations. Checked without change: community join and affiliate sign-up (e-mail only, matched by it), the SEPA mandate page and quote acceptance (they write no contact fields). | followup › a form with a known number and another address …; … known address and another number …; the public booking page …; a chat visitor …; nor does the chat bot …; the rule is an option |
| U-1 | modules/public-upload-quota.ts (new), forms.ts, cloud.ts |
Public form uploads had no disk quota per sub-account (only 12 MB per request and the per-minute brake). Now PGHL_PUBLIC_UPLOADS_MB (default 2048 MB per sub-account) and, in the cloud, the plan’s media storage (form files joined the fair-use meter) – whichever is lower. Over the cap: 413 before anything is written; the sub-account is told by e-mail and bell/push at 90 % and 100 %, once per level until the usage drops below 90 %. |
followup › disk quota …; signup › public form uploads and the plan’s media storage |
| D-1 | services/werkstatt/ops.ts, modules/accounting.ts, accounting-reports.ts, services/acc-autopost.ts |
The last UTC “today” spots: the plugin workshop’s sample data (a sample birthday “today” did not match the context’s local today between 00:00 and 02:00 in Berlin), the accounting’s default report period and period year, and the booking date of Stripe refunds now use the sub-account’s time zone (todayYmd). |
followup › today in the sub-account’s time zone (00:30 on 1 January, pinned clock) |
Remaining limits of these fixes: names and other non-identity fields from public input still update a matched contact (as before); two chat visitors on one contact (the real one and somebody who typed the address) both see the messages exchanged after the second one started – the team sees both in one conversation; the upload quota covers public forms (other uploads come from logged-in people and are covered by the cloud’s storage gate).
Checked without findings (this round)
Section titled “Checked without findings (this round)”- Stripe (
/hooks/stripe,/hooks/stripe-agency): constant-time HMAC, 5-minute tolerance, event ids de-duplicated before handling, every handler scopes by the location of the event; Checkout amounts always from the product row (forms with a payment field, quotes, invoices). - Quote page
/q/<token>: 24 random bytes, the selection is validated against the stored document, amounts are recomputed on the server, acceptance is claimed with a singleUPDATE … WHERE status IN ('sent','viewed'). - Consent: double-opt-in links (HMAC, expiry in the token and the row, a button instead of a GET), preference centre token per contact, redirect only to the sub-account’s own setting.
- Tracking:
/t/c/<id>redirects only to links the server wrote itself, trigger-link signatures cannot be extended (truncated HMAC), the pixel validates nothing it does not store. - Hosted course video: signed token bound to video, member and sub-account and the member’s session and a running enrolment, same-origin only, HLS names from a strict pattern, ffmpeg arguments built from server-made paths.
/uploads-opt/<w>/<file>: widths from a fixed list, file names through the uploads table, at most two conversions at a time, one cached copy per width and format.- Web push endpoints are checked against the push services’ hosts at subscription and at delivery; Slack/SNS/Meta media hosts are checked before any fetch; the importers’ absolute
nextlinks still go throughsafeFetch. - Output: a second stored-XSS sweep that also fails on a raw quote or apostrophe next to the payload (attribute breakout) over 5,200 public pages – clean. CSV exports (call analysis, funnel and course statistics, consent proof, payouts, member lists) neutralise formulas.
- Sales data in the leak harness: deal lines, a sent quote, a meeting note, a sequence enrolment with its events and a consent proof on a foreign contact are seeded as well; none of the 500+ routes hands them to a person limited to their own records (sequence templates are account-wide by design, like workflows – the enrolment is the contact’s data).
- Tenant export: credential columns and sealed values are stripped by name and by shape; no table with a push private key or provider credential is exported unsealed.