Skip to content

Instantly share code, notes, and snippets.

@simonw
Created June 9, 2026 00:42
Show Gist options
  • Select an option

  • Save simonw/0b29f301c2007808314eb04675c66916 to your computer and use it in GitHub Desktop.

Select an option

Save simonw/0b29f301c2007808314eb04675c66916 to your computer and use it in GitHub Desktop.

Review parent-child messaging

Consider how ./datasette-apps communicates between untrusted child and trusted parent.

10 previous messages

I’ll trace the parent/child boundary in ./datasette-apps first: where messages are sent, received, validated, and where trust decisions happen. Then I’ll give you the sharp edges rather than a generic security sermon.

Explored 1 list, ran 1 command
  • Ran git status --short
Explored 1 list
  • Listed files in datasette-apps

I found datasette-apps isn’t its own git repo from this cwd, so I’m treating it as a code tree rather than relying on history. The interesting words here are likely iframe, sandbox, postMessage, and the injected parent bridge, so I’m narrowing around those.

Explored 3 files, 1 search
  • Read ./datasette-apps/datasette_apps/rendering.py
  • Read ./datasette-apps/datasette_apps/templates/app_view.html
  • Searched for postMessage|message|iframe|sandbox|srcdoc|parent|child|allow-scripts|CSP|Content-Security|window\. in datasette-apps
  • Read ./datasette-apps/security-review.md

There’s already a focused security review in the tree, which is useful context, but I’m checking the implementation itself against that writeup. In particular I’m looking for whether the child can spoof replies, whether parent requests are capability-scoped server-side, and whether the message target/origin choices matter given srcdoc’s opaque origin.

Explored 4 files
  • Read ./datasette-apps/datasette_apps/views.py
  • Read ./datasette-apps/datasette_apps/rendering.py
  • Read ./datasette-apps/datasette_apps/data_access.py
  • Read ./datasette-apps/tests/test_playwright.py

The bridge is intentionally asymmetric: child messages ask, parent fetches, server checks; parent messages answer. One thing I’m noticing is that the child-side response listener currently accepts any datasette-app-response with a pending id, which may be okay under this threat model because the child is already untrusted, but it’s worth separating “protect parent from child” from “protect child promises from itself or child-created frames.”

Explored 5 files, 1 search
  • Searched for query_app|/-/apps/.*/query|run_app_query|run_app_stored_query|apps/.*/query|query\( in datasette_apps
  • Read ./datasette-apps/datasette_apps/__init__.py
  • Read ./datasette-apps/datasette_apps/csp.py
  • Read ./datasette-apps/tests/test_data_access.py
  • Read ./datasette-apps/datasette_apps/views.py
  • Read ./datasette-apps/tests/test_data_access.py

At this point the design looks mostly deliberate rather than accidental. The main notes I’d call out are protocol hardening and future-proofing: the current shape is fine for “untrusted child, trusted parent,” but small bridge changes could accidentally turn it into a broader confused-deputy surface.

Explored 6 files
  • Read ./datasette-apps/datasette_apps/rendering.py
  • Read ./datasette-apps/datasette_apps/views.py
  • Read ./datasette-apps/datasette_apps/rendering.py
  • Read ./datasette-apps/datasette_apps/templates/app_view.html
  • Read ./datasette-apps/datasette_apps/csp.py
  • Read ./datasette-apps/datasette_apps/data_access.py

I looked through the parent/child bridge. The overall shape is sensible for “untrusted child, trusted parent”:

The child iframe is sandboxed with allow-scripts allow-forms, but not allow-same-origin, top navigation, or popups: app_view.html. The child gets a restrictive injected CSP and the parent page also sends frame-src 'none';: csp.py, views.py.

The important trust boundary is in the parent bridge: it only accepts messages whose event.source is exactly the app iframe’s contentWindow: rendering.py. That is the right core check for a sandboxed srcdoc frame, where useful origin checks are limited. Query execution then goes through /-/apps/{id}/query, which re-checks view-app, app-level database/query allow-lists, and Datasette actor permissions server-side: views.py, data_access.py.

Two hardening notes stand out:

  1. The child response listener accepts any datasette-app-response with a pending id: rendering.py. That does not endanger the trusted parent, but it means child-created frames or child code can spoof responses to the child’s own promises. Mostly integrity/noise, not privilege escalation.

  2. The parent treats datasette-app-query and datasette-app-stored-query identically, then lets the server infer stored-query mode from whether "query" is present in the body: rendering.py, views.py. It is fine today because both capabilities are granted to the same untrusted app and checked server-side, but I’d make the operation explicit before adding more bridge capabilities.

Bottom line: the parent is protected in the current design. The main thing to preserve is exact iframe event.source scoping plus server-side permission checks; the main thing I’d tighten is protocol explicitness so future bridge features don’t accidentally inherit too much authority.

Currently we send messages back and forth over postMessage() between parent and child.

There's a theoretical security hole where the child frame might somehow navigate to an attacker-controlled domain (which the CSP rules no longer apply to) which could then use postMessage() to the parent to run read-only SQL queries etc.

I'm not convinced this hole is available, given this CSP header that forces the child iframe to only load content from our own domain:

https://github.com/datasette/datasette-apps/blob/1cfe5fe9334146947517af6e713cc2a2e1dc25d4/datasette_apps/csp.py#L13-L14

But defense in depth is good! Having the parent and child communicate via a MessageChannel() would eliminate this risk entirely, since the moment the child iframe navigated away the message channel would become invalid.

Discuss this.

1 previous message
Searched web 2 times
Searched web 2 times
  • Searched web for https://github.com/datasette/datasette-apps/blob/1cfe5fe9334146947517af6e713cc2a2e1dc25d4/datasette_apps/csp.py
  • Searched web for 'port2' in https://developer.mozilla.org/en-US/docs/Web/API/Channel_Messaging_API/Using_channel_messaging

Yes, I think your instinct is right: the current hole is probably theoretical, but MessageChannel() is still a cleaner capability boundary.

The existing parent CSP is stronger than “only our own domain”: at that commit it is literally APP_VIEW_PARENT_CSP = "frame-src 'none';" in csp.py. So the parent document is saying “this page must not load frames from anywhere.” The fact that the initial srcdoc iframe works is a special-ish case, but attempted iframe navigation to https://attacker.example/... should be blocked by the parent page’s frame policy. Your Playwright tests also make that more convincing than spec-lawyering alone.

That said, the current postMessage() bridge gives authority to the iframe browsing context: parent accepts messages where event.source === iframe.contentWindow. If the iframe’s current document somehow became attacker-controlled while preserving that contentWindow identity, the parent bridge would still see it as the right sender. That is the subtle worry.

MessageChannel() changes the model from “who is this window?” to “does this script hold the private port object?” MDN describes it as creating two linked MessagePort objects, and the normal iframe pattern is: parent creates the channel, keeps port1, transfers port2 to the iframe with one initial postMessage() call, then all real communication happens over the port. MDN’s guide shows exactly that transfer-to-iframe flow here, and notes that channel messaging is meant for direct communication between browsing contexts here.

The security improvement is real but should be phrased carefully:

  • If the child navigates to an attacker page, the new document does not automatically inherit JS heap objects from the old document, including the MessagePort.
  • The parent no longer needs to accept privileged query requests from window.message events at all after setup.
  • A hostile document that merely occupies the iframe later cannot manufacture the port.
  • The initial postMessage("*", [port]) is acceptable because it happens to the freshly-created srcdoc frame, but it should be paired with a startup nonce/handshake so only the injected bridge claims the port.

The one caveat: MessageChannel() does not protect against the original untrusted app code. Once you give the stored app bridge a port, that app can use it to ask for queries, just as today. It could also deliberately leak or proxy its own bridge API inside its document. But that is already inside the intended threat model: untrusted app code is allowed to request app-scoped Datasette data, and CSP is what prevents arbitrary exfiltration.

I’d implement it like this:

  1. Parent creates const channel = new MessageChannel().
  2. Parent sends port2 to iframe once, probably after iframe load, using a setup message with a random nonce.
  3. Child injected bridge listens for one setup message, checks the nonce/type, stores event.ports[0], then removes the global message setup listener.
  4. Query, stored query, app error, and open-link messages all move over the port.
  5. Parent closes and recreates the channel on iframe load/navigation, and ignores global message for privileged operations.
  6. Keep the server-side checks exactly as-is.

So: I agree the current CSP likely blocks the scary navigation case. But MessageChannel() would make the bridge feel much more object-capability-shaped, and it reduces the future risk that some browser navigation oddity, iframe attribute change, or relaxed CSP accidentally turns event.source === iframe.contentWindow into too broad a trust decision.

Implement this with red/green TDD using Playwright tests, run tests with uv run pytest ...

36 previous messages

I’ll make this properly test-led: first add a Playwright regression that demonstrates the current postMessage() bridge still trusts a navigated/replaced iframe window, run it to see red, then move the bridge to a transferred MessagePort and rerun the focused tests.

Explored 3 files
  • Read ./datasette-apps/tests/test_playwright.py
  • Read ./datasette-apps/tests/test_csp.py
  • Read ./datasette-apps/pyproject.toml

I’m going to encode the theoretical failure as a concrete browser test by replacing the iframe document after the app page loads, then having that replacement document try the old global postMessage() query protocol. Today that should get a result; the secure behavior is that it gets nothing.

Edited 1 file
  • Wrote ./datasette-apps/tests/test_playwright.py (+67 -0)

The red test is in place. I’m running just that Playwright test now, using uv run pytest from the plugin directory so the environment matches the project instructions.

Ran 1 command
  • Ran uv run pytest tests/test_playwright.py::test_replaced_iframe_document_cannot_use_global_query_messages

Red confirmed: the replacement document successfully got rows through the old global message protocol. Now I’m switching the privileged path to a one-time channel handshake, with the channel created inside the injected child bridge before app HTML runs.

Explored 1 file
  • Read ./datasette-apps/datasette_apps/views.py

I’m also moving the parent setup script ahead of the iframe. That lets the parent listen for the injected bridge’s one-time “here is my port” handshake before any app-authored HTML can race it.

Edited 1 file
  • Wrote ./datasette-apps/datasette_apps/rendering.py (+92 -29)

I caught one implementation detail while patching: once traffic is on a MessagePort, message events don’t have the same source window semantics. The source check belongs only on the one-time port transfer; after that, possession of the port is the authority.

Edited 2 files
  • Wrote ./datasette-apps/datasette_apps/rendering.py (+0 -3)
  • Wrote ./datasette-apps/datasette_apps/views.py (+8 -2)

Next I’m wiring the template so that the parent’s one-time handshake listener is installed before the iframe begins executing its injected bridge. That ordering is the heart of making the port private to the initial child document.

Edited 1 file
  • Wrote ./datasette-apps/datasette_apps/templates/app_view.html (+1 -1)

The main bridge code is patched. I’m doing a quick readback now because these cross-document handshakes are fussy: one misplaced listener or load-order assumption can make everything look secure while silently breaking normal apps.

Explored 4 files
  • Read ./datasette-apps/datasette_apps/rendering.py
  • Read ./datasette-apps/datasette_apps/rendering.py
  • Read ./datasette-apps/datasette_apps/views.py
  • Read ./datasette-apps/datasette_apps/templates/app_view.html

One subtlety in the readback: normal query responses now travel back on the port, but the parent still keeps all query permission checks server-side. So the channel is a transport hardening, not a replacement for the existing authorization layer.

Explored 3 files
  • Read ./datasette-apps/datasette_apps/rendering.py
  • Read ./datasette-apps/datasette_apps/views.py
  • Read ./datasette-apps/datasette_apps/rendering.py

I’m spotting a truncation in the generated parent script after the async handler, so I’m checking whether that’s just my read range or a real patch mistake before running tests.

Explored 1 file, ran 1 command
Explored 1 file
  • Read ./datasette-apps/datasette_apps/rendering.py
  • Ran uv run python -m py_compile datasette_apps/rendering.py datasette_apps/views.py

The truncation was only the read window; Python compiles cleanly. I’m running the red test and the normal query-bridge test together now, since those are the two sides of the change.

Edited 1 file, ran 1 command
  • Wrote ./datasette-apps/tests/test_playwright.py (+1 -1)
  • Ran uv run pytest tests/test_playwright.py::test_datasette_query_bridge_returns_data_to_iframe tests/test_playwright.py::test_replaced_iframe_document_cannot_use_global_query_messages

Good green on the two core browser tests: normal apps still query, replaced iframe documents no longer can. I’m broadening to the bridge/CSP unit tests next, then I’ll run the full datasette-apps test suite if those are clean.

Ran 1 command
  • Ran uv run pytest tests/test_csp.py

The CSP/bridge unit tests are clean too. I’m running the full suite now; this touches a browser subprocess and a real Datasette subprocess, so it’s the best check for any handshake timing weirdness.

Ran 1 command
  • Ran uv run pytest

Full suite is green: 141 passed with one unrelated Datasette deprecation warning. I’m doing a final diff pass now so I can summarize the exact security and behavior changes without hand-waving.

Ran 2 commands
  • Ran git status --short
  • Ran git diff -- datasette_apps/rendering.py datasette_apps/views.py datasette_apps/templates/app_view.html tests/test_playwright.py

The diff is tight. I’m adding a couple of cheap unit assertions around the new channel handshake too, so a future refactor doesn’t accidentally drift back to privileged global message handling while the browser regression catches only the full behavior.

Edited 1 file
  • Wrote ./datasette-apps/tests/test_csp.py (+4 -0)

Those assertions are in. I’m rerunning the bridge unit file and then the full suite one last time to make sure the final state is boring in the best possible way.

Ran 1 command
  • Ran uv run pytest tests/test_csp.py tests/test_playwright.py

Focused bridge tests are still green. I’m running the whole suite again after the unit-test additions, then I’ll wrap this up with the red/green evidence and changed files.

Ran 1 command
  • Ran uv run pytest

Everything is green after the final run. I’m collecting line numbers for the files I touched so the summary points at the actual bridge and regression test instead of floating above the code.

Explored 4 files
  • Read ./datasette_apps/rendering.py
  • Read ./datasette_apps/views.py
  • Read ./datasette_apps/templates/app_view.html
  • Read ./tests/test_playwright.py

Implemented the MessageChannel() bridge.

The child bridge now creates a private MessageChannel, keeps one port, and transfers the other to the parent during a one-time nonce handshake: rendering.py. The parent only accepts that first port transfer from the expected iframe window with the per-render token, then all privileged messages move over the port: rendering.py.

I also moved the parent bridge script before the iframe so the listener is installed before the injected child bridge runs: app_view.html. Each app render gets a fresh secrets.token_urlsafe(32) handshake token: views.py.

TDD evidence: I first added a Playwright regression where a replaced iframe document tries the old global postMessage() query protocol: test_playwright.py. It failed red by successfully retrieving rows. After the channel change, it passes.

Verification run with uv run pytest:

141 passed, 1 warning in 14.46s

The warning is an existing Datasette deprecation warning, not from this change.

Help me understand that test_replaced_iframe_document_cannot_use_global_query_messages test

That test is a small simulated attack against the old bridge.

It does this:

  1. Creates a normal app that is allowed to query the content database.
  2. Opens the app page and waits for the original iframe app to load.
  3. From the trusted parent page context, forcibly replaces the iframe’s srcdoc with a new “attacker” document.
  4. That replacement document does not have the injected Datasette bridge or the MessagePort.
  5. The replacement document tries the old protocol anyway:
parent.postMessage({
  type: "datasette-app-query",
  id: 4242,
  input: {
    database: "content",
    sql: "select name, score from items order by score desc"
  }
}, "*");

Before the fix, the parent bridge listened for global window.message events and trusted anything where event.source === iframe.contentWindow. After replacing the iframe document, that condition still held, so the fake document could ask the parent to run the query. The test failed red because attack_result contained rows.

After the fix, the parent only accepts the initial MessageChannel port transfer from the original injected bridge. Privileged query messages must arrive over that private port. The replacement iframe document can still call parent.postMessage(...), but the parent no longer treats that as a query request. So the test waits 500ms and expects no reply:

assert attack_result is None

So the test proves this specific defense-in-depth property: replacing or navigating the iframe document is not enough to recover query authority, because query authority is now possession of the transferred MessagePort, not merely being the current document inside the same iframe window.

add more clarifying comments to it

4 previous messages

I’ll add comments inside the Playwright test itself, focused on the attack setup and why None is the secure result. Then I’ll run that one test so we don’t accidentally disturb the JavaScript string.

Edited 1 file
  • Wrote ./datasette-apps/tests/test_playwright.py (+15 -0)

The comments are in the test, including inside the injected replacement document so future readers can see exactly what the fake app is trying. I’m running the single regression now.

Ran 1 command
  • Ran uv run pytest tests/test_playwright.py::test_replaced_iframe_document_cannot_use_global_query_messages

Added clarifying comments to test_playwright.py, covering:

  • what old behavior the regression protects against
  • why replacing iframe.srcdoc is a useful simulation
  • why the fake document uses raw parent.postMessage()
  • why attack_result is None is the secure outcome

Verified with:

uv run pytest tests/test_playwright.py::test_replaced_iframe_document_cannot_use_global_query_messages
1 passed

I don't like None as the secure result, can we come up with something a bit more obvious than thatL

6 previous messages

Yes, that’s a good readability gripe. I’ll change the timeout path to return an explicit sentinel like {"status": "no-response"} and assert on that, so the test says what security property it observed instead of relying on None as a little shrug.

Explored 2 files
  • Read ./tests/test_playwright.py
  • Read ./tests/test_playwright.py

I’m editing just the test harness result shape: an attack response will still include the query response, but a secure timeout will now resolve to a named outcome.

Edited 1 file
  • Wrote ./datasette-apps/tests/test_playwright.py (+2 -2)

That reads much more directly now. I’m running the single test to make sure the Playwright serialization and assertion behave as intended.

Ran 1 command
  • Ran uv run pytest tests/test_playwright.py::test_replaced_iframe_document_cannot_use_global_query_messages

Yep, changed it to an explicit sentinel:

assert attack_result == {"status": "no-response-from-parent"}

The timeout path now returns that object instead of None, so the secure result reads as “the parent ignored the fake global message” rather than “nothing happened, maybe?”

Verified:

uv run pytest tests/test_playwright.py::test_replaced_iframe_document_cannot_use_global_query_messages
1 passed

explain setTimeout(() => finish({status: "no-response-from-parent"}), 500);

That line is the test’s “success by silence” timer:

setTimeout(() => finish({status: "no-response-from-parent"}), 500);

It means:

  • wait 500ms
  • if no parent response arrived during that time
  • resolve the Playwright page.evaluate() promise with {status: "no-response-from-parent"}

The fake replacement iframe is trying to trick the parent with:

parent.postMessage({ type: "datasette-app-query", ... }, "*");

If the parent is still vulnerable, it replies quickly with datasette-app-response, and the test returns that response instead.

If the parent is secure, it ignores the fake global message. Since ignored messages do not produce an event or error, the test needs a timer to stop waiting and say: “good, no response came back.”

So the 500ms is not part of the app behavior. It is just the test harness giving the attempted attack a short window to succeed.

why 500ms ?

500ms is a pragmatic browser-test timeout: long enough for the vulnerable path to complete a same-origin parent fetch() to the local Datasette server and post a reply, short enough to keep the test suite fast.

In this test, the red/vulnerable version replied well within that window. The parent and server are both local, and the query is tiny, so if the old global bridge is active, it should answer almost immediately.

It’s not a security boundary, though. It’s just the test’s observation window. If you want it to read less arbitrary, I’d extract it:

const ATTACK_RESPONSE_TIMEOUT_MS = 500;
setTimeout(
  () => finish({status: "no-response-from-parent"}),
  ATTACK_RESPONSE_TIMEOUT_MS
);

Could also bump it to 1000 for extra CI slack, but 500 is probably fine given this is local Playwright plus local Datasette.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment