Security posture
August 7, 2026 · View on GitHub
What Publisher does and does not defend against, stated once so individual features can be judged against it instead of each inventing their own answer.
This is a statement of the current posture plus the known gaps in it. It is not a claim that Publisher is hardened; several things below are open, and they are listed rather than glossed.
For how to report something, and for the line between "working as documented" and a vulnerability, see SECURITY.md. That policy decides what gets triaged as a report; this document explains the posture it decides against. Where the two touch, the policy is the authority on reportability: the unauthenticated API and the permissive default framing policy are both classified there as working as documented, so the gaps below are arguments for changing the design, not vulnerability reports.
The trust boundary
Publisher trusts the operator and the packages the operator registers. It does not authenticate end users.
Concretely:
- The API is unauthenticated. REST on
:4000and MCP on:4040have no authn or authz. Anyone who can reach the port can list packages, compile Malloy, and run queries against every connected database. This is deliberate and documented — network isolation or an authenticating gateway is the intended control, not anything in the process. - Package content is first-party code. A package's models, notebooks, and
public/files are treated as code the operator chose to run, the same way you would treat your own web app deployed on your own origin. Publisher does not scan, sandbox, or vet them. - Registering a package is an operator action. Packages come from
publisher.config.jsonor aPOSTto the packages endpoint. That endpoint is gated only byfrozenConfig, so on a reachable server with the default config it is open — but so is the query API, and an attacker who can register a package can already read the data directly. Set"frozenConfig": trueto close registration on a deployment where that matters. - Governance is a modeling concern, not a request concern.
#(authorize), given-scoped row-level access,explores, andqueryableSourcesconstrain what a model exposes. They are real, and they are the right place to put data policy. They are not end-user authentication: a given is whatever the caller sends.
The corollary that keeps coming up in design review: a feature cannot be made safe by sandboxing it if an equivalent capability is available unsandboxed next to it. Isolation is worth building when it closes a boundary, not when it decorates one of several open doors. This is why the custom JSX dashboard sandbox was cut after it was built and working — see malloyyo-dashboards-design.md.
Where author code executes today
One surface runs author-written JavaScript, and it runs it with everything the viewer has.
HTML data apps (a package's public/ directory) are served as top-level documents on the
same origin as the REST API. The consequences follow from that and are all intended:
- Page JavaScript can call any same-origin endpoint directly.
Publisher.queryis a convenience wrapper, not a capability boundary. - Requests carry cookies (
credentials: "include"), so a page acts with the viewer's authority wherever a gateway has established one. - The only CSP on these documents is
frame-ancestors. There is noscript-src, so a page may load and run anything, including third-party scripts. - The routes are unauthenticated, and only
public/is reachable. Path traversal is blocked lexically and again throughrealpath, and a symlink escaping the directory returns 403.
Notebooks and dashboards execute no author code. A .malloynb is markdown and Malloy cells;
a dashboards/*.malloy is Malloy plus renderer tags. Both are declarative, which is what makes
them reviewable in a pull request and safely agent-authorable. Keeping them that way is a
deliberate property, not an accident of scope.
Known gaps
These are open, ordered by how much they would matter on a deployment that has put a gateway in front of Publisher. None are fixed as of this writing.
1. Everything Publisher serves is framable by any origin, and the knob that looks like it fixes
that only covers part of it. In-package HTML gets Content-Security-Policy: frame-ancestors *
by default, a standing clickjacking vector for any page with a control worth clicking.
PUBLISHER_FRAME_ANCESTORS narrows that — but only for files under a package's public/. The
Console catch-all sets no framing header at all and there is no global X-Frame-Options, so
notebooks, dashboards, models, and the Explorer stay framable from anywhere on a deployment that
has set the variable. That is worse than a permissive default, because setting the variable
implies a coverage it does not have. Two fixes, in order: apply one policy to every document
(#930), then reconsider the default —
'self', with embedding opt-in per deployment, costs embedders one env var and closes this for
everyone else.
2. There is a token-shaped thing that authenticates nothing. Publisher.embed appends an
embed_token query parameter, and Publisher.setToken attaches an Authorization: Bearer
header — and no server code reads either one. The docs describe signed embed tokens as a next
step, so this is unfinished rather than broken, but an affordance that looks like authentication
and is not is worse than its absence: it invites an integrator to believe a page is protected.
Either verify it or remove it until it can be verified. It also gates widening embedding to more
surfaces (#931).
3. Package markdown is rendered with raw HTML parsing enabled. markdown-to-jsx runs with
its default disableParsingRawHTML: false, so raw HTML in a package's markdown becomes JSX.
React will not execute an inline <script> this way, and link hrefs are already scheme-checked
precisely because packages can come from untrusted git or S3 sources — so this is a narrow
surface, not an open one. Still, it is the one place a declarative artifact touches
author-controlled HTML, and it is worth either disabling raw HTML or sanitizing deliberately.
Three call sites, not one: notebook cells, workbook cells, and an environment's About panel
(NotebookCell.tsx, MutableCell.tsx, About.tsx). None passes the option, so fixing one and
calling it done would leave the other two open.
4. Resize messages are not origin-checked. Both the in-page host runtime
(packages/server/src/runtime/publisher.js) and the Console's data-app viewer
(PageViewer.tsx) validate event.source against the iframe's contentWindow but never
event.origin. Source-matching is the stronger of the two checks and the payload is a single
number, so the exposure is bounded, but the check is one line.
If isolation gets built
The mechanism to reuse already exists, preserved out of tree from the cut custom-JSX sandbox
(see malloyyo-dashboards-design.md): an
<iframe sandbox="allow-scripts"> in an
opaque origin, a default-src 'none' CSP with connect-src 'none' so the guest has no network
at all, a per-request nonce for injected state, and a postMessage broker in the trusted parent
that validates each run before executing it. It was built for dashboards and cut with them.
Pointed at HTML data apps instead, it would raise the floor for the surface that actually runs
author code (§Where author code executes today). It cannot be the default: an opaque origin breaks
credentials: "include", direct fetch, and third-party scripts, which is to say it breaks
every page written against the current contract. The shape that fits is a per-package opt-in,
where a package declares it wants isolation and its data apps talk to the broker instead of to
the API directly.