Security
How Pinwren protects your site, your visitors and your feedback — and how to report a vulnerability.
Pinwren runs on other people's websites (the widget) and holds their feedback, screenshots and console output (the server). This page summarises the threat model, what is implemented today and how to harden your instance.
Threat model
| Threat | Surface | Mitigation in Pinwren |
|---|---|---|
| Spam and abuse of the public widget API | /w/* | Origin allowlist per project; per-IP, per-project rate limits stored in Postgres (429 RATE_LIMITED); schema-validated, size-capped payloads; a read-only mode that refuses new feedback. |
| A project key used from another site | /w/* | The key is public by design. Every request must carry an Origin on the project's allowlist; hostnames match exactly (example.com never matches evil-example.com), with explicit *. wildcards. |
| Someone else resolving your report | PATCH /w/feedback/:id | Visitors can resolve only when the project allows it; otherwise only the original reporter, proven by a random 32-byte token returned once at submission and stored as a SHA-256 hash. |
| The widget breaking or reading the host page | Widget | A closed Shadow DOM, no global CSS or JavaScript, every entry point wrapped so nothing throws into host code, focus traps that return focus, and lazy chunks loaded only on demand. |
| Stored XSS through feedback content | Dashboard, emails, chat, issues | The dashboard renders untrusted text as text. Email templates are HTML-escaped before Markdown is parsed and variables are inserted escaped. Slack and Discord payloads escape markup and disable mentions; issue bodies entity-encode text and break @/# references. |
| Malicious uploads | Screenshots, attachments | The first bytes must match the declared type (PNG, JPEG, WebP, GIF, PDF); size caps; SVG refused; random storage keys; served only to members with Content-Security-Policy: default-src 'none'; sandbox and nosniff; anything but an image downloads instead of rendering. |
| Cross-workspace access | /api | Every query on workspace data is scoped to its organisation; assets are looked up by id and organisation. |
| Session theft and CSRF | /api | Better Auth sessions with a trusted-origin check against PINWREN_URL; cookie-carrying writes must come from Pinwren's own origin (Origin or Sec-Fetch-Site: same-origin) and be JSON (415 otherwise); a Content-Security-Policy on every dashboard page; an instance-wide session lifetime that is a hard limit from sign-in; optional HTTPS enforcement with HSTS. |
| Server-side request forgery | Webhooks, panel actions, trackers | One outbound guard for every URL a user enters: private, internal and metadata addresses refused (after DNS resolution, connecting only to the checked address), no redirects followed, timeouts and response caps, and no response body ever shown back. PINWREN_OUTBOUND_ALLOWLIST admits internal targets you choose. |
| Spoofed client addresses | Rate limits, sign-in limiter | PINWREN_TRUST_PROXY counts trusted proxy hops and takes the address from the right of X-Forwarded-For; X-Real-IP is never read; with no trusted proxy only the socket address counts. |
| Taking over a fresh instance | Sign-up, instance admin | The first account administers the server (keep it private until you have signed up), plus PINWREN_ADMIN_EMAILS; the promotion is serialised so two first sign-ups can't both win; sign-up closes after the first account unless PINWREN_DISABLE_SIGNUP=false. |
| Guessing API tokens | /api/v1, /mcp | 20 failed tokens per address in 10 minutes, then 429 RATE_LIMITED for the rest of the window. |
| Oversized requests | Every public endpoint | Body caps enforced while the request streams in, before parsing: 2 MB on /api and /mcp, 48 MB per report (with a required Content-Length), 64 KB for widget replies, 1 MB for inbound webhooks; per-project report and storage caps. |
| Leaked credentials | SMTP, webhook and tracker secrets | AES-256-GCM at rest with a key derived from PINWREN_SECRET (HKDF); write-only in the API. Slack and Discord webhook URLs, and the Slack app's signing secret and bot token, are encrypted the same way and only ever returned masked or not at all. API tokens are stored as scrypt hashes and shown once. |
| Secrets in captured diagnostics | Network capture | Request URLs are recorded with sensitive query parameters (any name containing token, key, secret, pass, auth, session, code or sig) replaced by [redacted]; request and response bodies are never captured. |
| Spoofed webhooks | Inbound tracker webhooks | GitHub X-Hub-Signature-256 HMAC and GitLab X-Gitlab-Token verified in constant time against a per-integration secret; inbound bodies capped at 1 MB; each delivery acted on once (repeats within 7 days are ignored). |
| Impersonated reporters | Widget identity | Pinwren.identify() names are shown as typed unless signed: a reporter is marked Verified only when your server signed the user id with the project's identity secret (HMAC-SHA256), checked in constant time. The secret never reaches the browser and the signature is never stored. |
| Clients or stakeholders changing data | /api, /api/v1, /mcp | The Viewer role reads everything; every mutating request is refused with 403 READ_ONLY_ROLE, and a viewer's API tokens are read-only. |
| Personal data in session replays | Replay | Off by default per project; every input is masked, .pinwren-block and data-pinwren-ignore elements are not recorded, .pinwren-mask, data-pinwren-mask and the project's mask selectors have their text replaced, the reporter can switch it off per report, and it is off in privacy mode unless the host page allows it (then all text is masked too). |
Pre-release hardening
Before the first release, an independent review led to these changes (ADR 0021):
- SSRF protection. Webhooks, Slack and Discord channels, webhook panel actions and per-integration tracker URLs
all go through one outbound guard. It resolves the host, refuses loopback, private, CGNAT, link-local, cloud
metadata, unique-local, multicast and reserved addresses (including IPv6 forms that embed them), connects only to
the address it checked, never follows redirects, and caps time and response size. Test buttons and delivery logs
show a status code and an error category, never the remote response. Internal targets need
PINWREN_OUTBOUND_ALLOWLIST. - Trusted-proxy client IPs.
PINWREN_TRUST_PROXYis a hop count, the address comes from the right ofX-Forwarded-For, andX-Real-IPis ignored — see client addresses. The sign-in limiter uses the same address and keeps its counters in Postgres, shared by all workers. - First account. The first account created on a server is its administrator (there is no setup token), so
keep a new server private until you have signed up.
PINWREN_ADMIN_EMAILSnames more; sign-up closes after the first account by default. See the first account. - Accounts. Password reset links work once and sign every device out; two-factor authentication (TOTP with backup codes) is available to everyone; reset, verification, two-factor and sign-in endpoints are rate limited.
- CSRF and CSP. Cookie-authenticated writes must be same-origin JSON. Dashboard pages send a fixed
Content-Security-Policy (no third-party origins,
object-src 'none',base-uri 'none',frame-ancestors 'none'), which also keeps session replays from loading remote resources. Inline scripts stay allowed because the prerendered page shells can't carry nonces; XSS defence rests on escaping, the Markdown sanitiser and http(s)-only links. - Body caps. Every limit is enforced while the body streams in, before parsing — see bug report limits and API limits.
- Interactive Slack. Every request from Slack is checked against the app's signing secret (HMAC-SHA256 of the raw body, constant-time compare, five-minute replay window) before anything is read from it, and each trigger id is acted on once. A button acts as the Pinwren member found by confirmed email (or linked by the member after a confirmation), with that member's role, and can only reach the workspace its Slack team is connected to. See Slack.
- Encrypted chat webhook URLs. Slack and Discord URLs are encrypted at rest (existing ones on the first start of the new version) and only ever shown masked.
- Token brute-force limit. 20 failed API tokens per address in 10 minutes lock that address out of
/api/v1and/mcpfor the rest of the window. - Relays and admin-only configuration. Template test emails go only to the sender's own address, invitations are rate-limited, and project settings (including panel actions and trackers) can be changed by organisation admins only.
- Smaller fixes. Page URLs must be http(s), the replay viewer stops decompressing at 64 MB, the widget no longer serves threads of deleted items, and the CSV export neutralises formulas hidden behind whitespace.
Privacy by design
- Masking. Password fields, payment-card fields (
autocomplete="cc-…") and anything markeddata-pinwren-maskare always blocked out of captures as solid boxes;data-pinwren-ignoreremoves an element from the capture entirely. - Privacy mode (per project) masks every text field, drops page-timing data, reduces the user agent to the browser and OS family, keeps only errors in console capture, truncates stored IP addresses (/24 for IPv4, /48 for IPv6), asks for consent and turns off the instance's outbound update check. The server enforces the same rules for old or modified widgets. See Privacy & masking.
- Retention. Each project can delete feedback older than a number of days; the trash is purged after 30 days.
- No phone-home. Telemetry does not exist; the only outbound call the server makes on its own is the optional daily update check.
Hardening checklist
- Use a long random
PINWREN_SECRET(openssl rand -base64 48) and keep it out of version control. - Serve over HTTPS behind a reverse proxy. Set
PINWREN_TRUST_PROXYto the exact number of proxies in front of Pinwren (0when there is none), and considerPINWREN_ENFORCE_HTTPS=true. - Limit each project's allowed sites to the origins that really embed the widget. The origin allowlist and the rate limits are what protect ingest, not the key.
- Create the first account before anyone else can reach the server (or set
PINWREN_ADMIN_EMAILSfirst), and turn on two-factor authentication for administrators. Keep sign-up at its default (first account, then invitations only) unless you really wantPINWREN_DISABLE_SIGNUP=false. - Allowlist only what you need. Add internal webhook receivers or a self-managed GitLab to
PINWREN_OUTBOUND_ALLOWLISTby host name or a narrow CIDR, and never setPINWREN_OUTBOUND_ALLOW_PRIVATEoutside development. - Keep uploads private. With
STORAGE_DRIVER=s3, keep the bucket private; Pinwren streams every file itself. - Turn on privacy mode for projects whose pages show personal data, and mark sensitive elements with
data-pinwren-mask. - Mind where integrations send data. Issue sync and webhooks copy feedback text and links into other systems; use private repositories and tokens scoped to one repository.
- Upgrade promptly — instance admins see Update available — and back up the database and files.
Reporting a vulnerability
Please don't open a public issue
Report vulnerabilities privately through GitHub:
open a private vulnerability report
. Only the maintainers can see it.
Include what an attacker can do and who is affected (site visitors, workspace members, instance admins), the component and version (widget, dashboard, API, Docker image) and how Pinwren was deployed, steps or a proof of concept against your own instance, and any suggested fix.
| Step | Target |
|---|---|
| Acknowledgement | within 3 working days |
| Assessment and severity | within 7 days |
| Fix released | critical and high within 30 days; others within 90 days |
| Public advisory (GitHub, CVE) | when the fix is released, or as agreed with you |
We follow coordinated disclosure and credit reporters in the advisory and release notes unless they prefer to stay anonymous. Good-faith research that follows the policy, avoids privacy violations and service disruption, and only touches your own instance is welcome.
In scope: the server and dashboard, the widget (including the published @pinwren/widget), and the Docker
image and compose files — especially anything that breaks out of the widget's Shadow DOM, crosses workspaces,
bypasses authentication or API tokens, stores XSS, enables SSRF through webhooks, tracker URLs or the update
check, or exposes secrets or personal data that should be masked.
Out of scope: findings that need an already compromised admin account or server, missing hardening headers without demonstrated impact, denial of service by sheer volume, and dependency vulnerabilities with no path to exploit them through Pinwren.
The full policy is in SECURITY.md.