Skip to content

feat(security): verify widget manifest signatures before iframe load (#39)#123

Merged
PanoramicRum merged 3 commits into
mainfrom
phase-s2-39-widget-manifest-verify
Jun 5, 2026
Merged

feat(security): verify widget manifest signatures before iframe load (#39)#123
PanoramicRum merged 3 commits into
mainfrom
phase-s2-39-widget-manifest-verify

Conversation

@PanoramicRum

Copy link
Copy Markdown
Contributor

What & why

Closes #39 / Q-M-13WidgetFrame companion: no manifest signature verification before loading a widget URL. office-ui installed a marketplace type: 'widget' package by registering its WidgetManifest verbatim and loading manifest.entryUrl into an iframe with no signature check, so a tampered manifest could point entryUrl at attacker infrastructure. This is the host-side companion to the merged #15 (postMessage hardening).

Latent, not active. Every built-in widget today is entryType: "native" (in-bundle, no iframe) and the marketplace seed has only type: 'personality' packages — there are zero installable external widgets yet. This gate matters the moment the marketplace serves a third-party external widget.

No new crypto. packagehub already signs the whole version manifest (Ed25519) and exposes GET /packages/:name/versions/:version/verify. The signature covers the nested widget object, so a verified: true means the entryUrl is exactly what the publisher signed. This PR is host-side enforcement only.

Changes

office-ui — fail-closed enforcement at every checkpoint

  • verifyWidgetVersion() calls packagehub's /verify; strict verified === true coercion.
  • Pure widgetLoadDecision(entryType, verified) gate (native → allow; external → allow iff verified).
  • useInstalledWidgetsStore tracks verification in a parallel verifiedByWorkspace map + isVerified() (fail-closed default); persist version: 1 migration leaves pre-existing entries unverified.
  • Gates wired at install (block + toast, no register), boot hydration (skip unverified external), and render (placeholder instead of <WidgetFrame>).

Security fix found while finishing the work — verify/install version binding

  • The install flow verified the newest non-yanked version but extracted/installed the manifest from pkg.latestVersion — which can be a yanked version. That let a yanked-latest verify version X while registering version Y's entryUrl. New widgetInstallTarget() returns the manifest and the version from the same pkg.latestVersion, so they can't diverge. Regression-tested.

Spec reconciliation (@urule/spec)

  • The spec's embedded widget config used a dead entrypoint field with zero consumers (packagehub publishes with z.object({}).loose(), so it was never enforced). Replaced with the real runtime shape (entryType/entryUrl/componentPath/mountPoints/…) that widget-sdk and office-ui already use, so the signed field name is unambiguous across repos. JSON schema updated with conditional entryType requirements; added a widget-pack example + positive/negative validator tests.

Tests

  • office-ui: widget-frame-security (18) + useInstalledWidgetsStore (12) + marketplace-api (18, incl. the version-binding regression) — 48 pass.
  • spec: manifest-validator (18, incl. widget positive/negative) — 26 pass.
  • typecheck clean for @urule/office-ui and @urule/spec.

Out of scope / residuals (deferred)

  • /verify doesn't echo the signed entryUrl — acceptable because the host verifies the same version it installs and both read the same packagehub DB row (publisher trust = package ownership; a packagehub compromise is out of model).
  • Re-verify on boot hydrate to harden the localStorage-tamper vector — overlaps the open [Security][High] Office UI stores tokens in localStorage — XSS = full account takeover (H-05) #14 (localStorage→BFF); intentionally not added here.
  • Enforcing the spec manifest schema at packagehub publish time (kept .loose() to avoid touching non-widget package types).

🤖 Generated with Claude Code

PanoramicRum and others added 3 commits June 5, 2026 07:53
…#39)

Host-side enforcement of packagehub Ed25519 publisher signatures for external
(iframe) marketplace widgets — closes the latent #39 / Q-M-13 hole where a
tampered manifest could point entryUrl at attacker infrastructure. Native
in-bundle widgets are unaffected; fail-closed for legacy/unverified entries.

- gate install, boot-hydration, and render on packagehub's /verify endpoint
- bind the verified version to the installed manifest via widgetInstallTarget so
  a yanked-latest can't verify one version while registering another's entryUrl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#39)

The spec's embedded widget config used a dead `entrypoint` field with zero
consumers; packagehub publishes manifests with z.object({}).loose() so it was
never enforced. Replace it with the real runtime shape (entryType / entryUrl /
componentPath / mountPoints / ...) that widget-sdk and office-ui already use, so
the signed field name is unambiguous across repos. Adds a widget-pack example +
positive/negative validator tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-proof (#39)

Addresses an independent-review finding: the prior widgetInstallTarget unit
test pinned the helper's contract but not the call site, so a future revert that
verified the page-level (newest non-yanked) version again would pass silently.
Extract the install decision into a pure, dependency-injected
installWidgetFromPackage() and test it directly: verify is always called with the
manifest's source version; an external widget that fails (or throws during)
verification is never installed or registered; native widgets skip verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@PanoramicRum

Copy link
Copy Markdown
Contributor Author

Independent-context security review — outcome

Ran three fresh-context adversarial reviewers (gate-bypass, version-binding, spec) before requesting merge.

Clean:

  • Gate-bypass: no path lets an unverified/tampered external widget reach <iframe src=entryUrl> — install, boot-hydration, and render are all fail-closed; the store default + v0→v1 persist migration are fail-closed; uninstall clears verification so a reinstall can't inherit a stale verified:true.
  • Spec: draft-07-valid, no external consumer of the old entrypoint, conditional entryType requirements genuinely enforced by the new validator tests; spec now matches widget-sdk/office-ui field-for-field.

Addressed:

  • Test durability (placebo risk): the original widgetInstallTarget unit test pinned the helper but not the call site. Extracted the decision into a pure, dependency-injected installWidgetFromPackage() and added tests that fail if the call site ever verifies a different version than it installs, or if the fail-closed gate is removed (commit 1f42a44).

Accepted residual (not a security defect):

  • Point-in-time verification (TOCTOU between page render and install). If a new version is published between page-load and the user clicking Install, the host verifies + installs the in-hand pkg.latestVersion — but it verifies and installs the same version, so the registered entryUrl bytes are always covered by the signature. This is a freshness/UX consideration (you may install a slightly-older-but-authentically-signed version), not an entryUrl-injection hole. A pre-verify re-fetch would shrink but never close the window and isn't required for the [Quality][Medium] WidgetFrame companion: no manifest signature verification before loading widget URL (Q-M-13) #39 threat model. Separately, declining to install a yanked pkg.latestVersion is a product decision tracked as a follow-up, not a signature gap.

@PanoramicRum PanoramicRum merged commit 4eb8c51 into main Jun 5, 2026
6 checks passed
@PanoramicRum PanoramicRum deleted the phase-s2-39-widget-manifest-verify branch June 5, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant