security(widgets): add sandbox="allow-scripts" to widget iframes

Addresses the primary finding from the May 27 security report (issue #8):
the admin widget preview modal (frontend/js/views/widgets.js) and the web
player widget renderer (server/player/index.html, 2 sites) loaded
user-authored widget HTML into unsandboxed iframes. Same-origin scripts
in the widget content could access window.parent.localStorage and
exfiltrate the JWT.

sandbox="allow-scripts" without allow-same-origin sandboxes the widget
into a unique origin: inline scripts (clock, RSS, weather widgets)
continue to work, but parent-origin access and same-origin requests are
blocked. Verified via Playwright probe against all 6 widget types in the
dev DB (clock, rss, social, text, weather, webpage): each renders
correctly under the new sandbox and contentDocument access from the
parent is blocked (opaque-origin enforcement working). Admin preview
unchanged in appearance; player display unchanged.

Webpage widget (server/routes/widgets.js) sandbox tightening (drop
allow-same-origin) is a separate forthcoming commit - needs test against
real embed URLs since some sites rely on same-origin behavior. The
sandbox-attribute intersection rule means today's outer-iframe sandbox
will cascade and strip allow-same-origin from the webpage widget's inner
iframe too; accepted as a narrow cosmetic regression (cookies/localStorage
stripped for embedded sites) until the deliberate inner-iframe handling
ships.

SECURITY.md added with reporting process (GitHub Security Advisories
primary, support@bytetinker.net fallback) and scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-05-28 12:28:34 -05:00
parent 159a36ed99
commit fe36c8c4b9
3 changed files with 104 additions and 1 deletions

97
SECURITY.md Normal file
View file

@ -0,0 +1,97 @@
# Security Policy
Thanks for taking the time to look at ScreenTinker's security. The project
is a one-person open-source effort, so response times reflect that — but
reports are taken seriously and handled in good faith.
## Reporting a vulnerability
**Primary channel — GitHub Security Advisories (preferred):**
[github.com/screentinker/screentinker/security/advisories/new](https://github.com/screentinker/screentinker/security/advisories/new)
GitHub's private advisory flow keeps the report off public issues, lets us
draft a fix collaboratively, and produces a CVE if appropriate. Use this
unless you have a reason not to.
**Fallback — email:**
`support@bytetinker.net` (the maintainer's consultancy inbox; the domain
intentionally differs from `screentinker.com` — it's the actively-monitored
business address rather than a project-domain alias that might not have
working mail delivery).
Please include:
- A description of the issue and its impact
- Steps to reproduce (the more concrete, the better)
- The commit SHA or release tag you observed it on
- Any proof-of-concept code or payload, if you have one
## Response timeline
I aim to acknowledge reports within **35 business days** and update with a
triage assessment within **10 business days**. If you haven't heard back
in that window, please feel free to nudge — life happens, and reports
occasionally slip past.
Fix timelines depend on severity, complexity, and whether the issue is on
the hosted instance (screentinker.com) or affects self-hosted deployments
too. Critical issues affecting the hosted instance generally get same-week
turnaround.
## In scope
Reports about the following are welcome and treated as security issues:
- **Authentication / session bypass** (e.g. JWT forgery, login bypass,
privilege escalation)
- **Multi-tenancy boundary violations** (one workspace's data leaking into
another, organization-level isolation breaks)
- **XSS in widget rendering or admin UI** (e.g. unsandboxed widget content,
unescaped user input in dashboard surfaces)
- **CSRF** on state-changing endpoints
- **SQL injection** (deviations from parameterized queries are reportable)
- **Server-side request forgery** (SSRF) via widget URLs, content uploads,
webhook handlers, or similar
- **Insecure direct object reference** (accessing a resource by ID without
the proper tenancy gate)
## Out of scope
The following are acknowledged but not treated as in-scope security
issues for this project:
- **Denial of service via excessive resource usage** (uploading large
files, opening many sockets, etc.) — operational concerns, not security
vulnerabilities. Rate limits exist where it matters most.
- **Social engineering** of the maintainer or other users
- **Misconfigurations of self-hosted instances** (e.g. exposing the server
to the internet without TLS, weak JWT secrets, default passwords). The
README documents recommended configuration; deviations are the operator's
responsibility.
- **Vulnerabilities in third-party dependencies** (Express, better-sqlite3,
socket.io, etc.) — please report those upstream. If a dependency CVE
affects ScreenTinker in a non-obvious way, that's worth flagging here too.
- **Reports generated by automated scanners** with no manual triage or
proof-of-concept (e.g. "your /robots.txt is missing" — not what this
project worries about)
## Coordinated disclosure
Please **wait until a fix has shipped to the hosted instance and
origin/main before public disclosure**. I'll keep you in the loop on
timing and confirm when it's safe to publish. For most issues that
window is a few weeks at most; if it stretches longer, that's a signal
something is more complex than expected and we'll coordinate.
If you find a critical issue that's being actively exploited (or you
believe might be), please say so in the report — I'll prioritize
accordingly.
## Acknowledgments
If you'd like to be credited for a report, I'm happy to acknowledge you
by name in release notes and (when applicable) in the GitHub advisory
itself. Let me know in your report whether you'd like credit and how
you'd like to be named. Anonymous reports are also welcome — no credit
is required.

View file

@ -114,7 +114,7 @@ function showPreviewModal(html) {
<strong style="color:var(--text-primary)">${t('widget.preview_title')}</strong>
<button class="btn btn-secondary btn-sm" id="pvClose">${t('widget.close')}</button>
</div>
<iframe id="pvIframe" style="flex:1;width:100%;border:0;background:#000"></iframe>
<iframe id="pvIframe" sandbox="allow-scripts" style="flex:1;width:100%;border:0;background:#000"></iframe>
</div>`;
document.body.appendChild(overlay);
// srcdoc resolves relative URLs against about:srcdoc, so inject <base> pointing to our origin

View file

@ -1434,6 +1434,9 @@
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render`;
iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000';
iframe.allow = 'autoplay; fullscreen';
// Sandbox into a unique origin so widget scripts can't read window.parent
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts');
mount.appendChild(iframe);
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
}
@ -1459,6 +1462,9 @@
if (zone.zone_type === 'widget' && assignment.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${assignment.widget_id}/render`;
// Sandbox into a unique origin so widget scripts can't read window.parent
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts');
div.appendChild(iframe);
} else if (isYoutubeZone) {
createYoutubeEmbed(src, assignment, div);