Skip to content
FrameworkJavaScript

Embedding any URL safely

What Blok enforces when it frames a link, and the headers your app must send.

Last updated Nov 5, 2026Edit this page on GitHub

What Blok enforces

Blok frames only links it trusts, and gives each frame as little power as it can.

  • Registry providers, such as YouTube and Figma, get the provider sandbox and referrerpolicy="strict-origin-when-cross-origin".
  • A generic embed needs linkPaste.allowGenericEmbed or a matching allowedEmbedOrigins entry.
    • Its source must be an absolute https:// URL. Forms like https:/x or https:x.com are refused, because the browser would read them against your own page.
    • Blok saves the canonical form, so https://Example.com is saved as https://example.com/.
    • It gets sandbox="allow-scripts allow-forms allow-popups", no allow list, no referrer, and credentialless (Chrome only).
    • By default it loads only after the reader clicks.
  • A generic embed never frames your own site. The page host, hostAliases, and their subdomains and parent domains get a link card.
  • blockedEmbedOrigins beats both allow settings.
  • The headless view frames registry embeds only. After transformUrl, the frame src must still be absolute https, or the view prints a link.

The file tool frames only blob: PDFs, re-typed as application/pdf. A remote http(s) PDF gets an "Open in new tab" link instead.

With no uploader, every blob: URL Blok makes gets a safe type. Any type outside a short safe list becomes text/plain, including HTML, XML, zip, Office files and untyped files. An SVG image is not a blob at all. Read a file's real type from the block's mimeType.

With no uploader, an SVG image is stored as a data:image/svg+xml URL. Opened in its own tab, an SVG blob runs its script as your site, and a data: URL does not.

  • Blok shows a data:image/svg+xml URL only as an image source: the <img> in the editor and in the headless view.
  • Links, downloads, frames and CSS still refuse it.
  • An SVG over 256 KB becomes a text/plain blob instead, so the image does not show. Set an uploader for larger SVG files.

A page inside an embed frame cannot take keyboard focus on its own.

  • If focus moves into a frame without a click, tap or Tab from the reader, Blok puts focus and the caret back.
  • After a touch press the guard stands down, so a tap into a field inside an embed still works.
  • In Firefox, a frame that grabs focus while the page is still loading can still get some keys before the reader clicks.

A frame's saved size stays in bounds. Its height stays between 0.1 and 4 times its width. A width or height that is not a positive number falls back to the default 580×320.

blocksToHtml and blocksToMarkdown trust a saved embed only as far as its URL.

  • An embed exported as a link uses the stored service name as its text only when the source URL matches that registry provider. Otherwise the URL is the text.
  • In blocksToHtml, a player keeps the looser player sandbox only if transformUrl keeps the provider's origin. Otherwise it gets the generic sandbox.

The rest of this guide is up to your app. Blok is a library, so it cannot send headers for your pages.

Limit frames with CSP

Send a Content-Security-Policy with a frame-src directive. The browser checks it for every frame on your page, so it still holds if a check in Blok has a bug.

  • To allow any https site, as allowGenericEmbed does, use frame-src https:.
  • To allow only some sites, list them. Match the list to allowedEmbedOrigins and the registry providers you use.
  • The inline PDF preview frames a blob: URL and reads it with fetch. A strict policy may need blob: in frame-src and connect-src. We have not measured this.

Keep your own pages out of frames

Send frame-ancestors 'self' or frame-ancestors 'none' on your own pages, such as settings and sharing. Then another site cannot frame them to trick a signed-in reader into a click.

In a generic embed, Blok refuses to frame your own host, its aliases, and their subdomains and parent domains. It does not catch a sibling subdomain, such as cdn.example.com next to app.example.com. That would need the public suffix list. List such hosts in hostAliases or blockedEmbedOrigins.

The host lists and the own-site check also catch two rarer address forms.

  • An IPv4 address written as IPv6, such as [::ffff:10.0.0.1], matches IPv4 entries in allowedEmbedOrigins, blockedEmbedOrigins and hostAliases. The own-site check matches it too.
  • When the editor runs in a document with no hostname, such as blob:, about:srcdoc or about:blank, Blok reads the host from the document's origin.

Cut the link to popups with COOP

A framed page can open a popup. Only video and audio players from the registry, such as YouTube, Vimeo and Spotify, keep allow-popups-to-escape-sandbox. YouTube's "Watch on YouTube" needs an unsandboxed tab.

Code, document and social embeds, such as CodePen, Figma, Google Docs and posts, do not get it. Neither does a generic embed. Their popups keep the frame's sandbox, so some provider pages may refuse to load in them.

An escaped popup is a normal page of the framed site. In Firefox it could send your editor's tab to another address through window.opener.top. Chrome and WebKit refused.

Send Cross-Origin-Opener-Policy: same-origin on the page that hosts the editor. You still need it for the players, whose popups escape. The popup then had no window.opener, and the tab stayed put in all three browsers. same-origin-allow-popups did not help: in Firefox the popup still moved the tab.

same-origin may also break popups your app opens on other sites and talks to through window.opener, such as a sign-in window. Test those flows before you turn it on.

Cap powerful features

A generic embed gets no allow list, so it gets no camera, microphone, geolocation or payment. A registry embed gets only encrypted-media, fullscreen and picture-in-picture.

To cap the whole page, send a Permissions-Policy header. Give each feature your app does not use an empty list:

  • camera=(), microphone=(), geolocation=(), payment=(), usb=()

Browser compatibility data says only Chrome enforces this header. Firefox and Safari ignore it, so the allow attribute stays the main lever. We have not measured this.

Serve user files from another site

An uploaded HTML or SVG file runs script as the site that serves it. On your app's own host it acts as one of your pages. Serve uploads from a separate host, such as an S3 bucket's own address.

A separate site is safer than a subdomain of your app, because browsers treat subdomains as the same site for SameSite cookies. If you use a subdomain, list it in hostAliases or blockedEmbedOrigins, so it never gets a generic embed.

A blob: URL has your page's origin too. Blok re-types the blobs it makes, but a blob: URL from your own uploader keeps the type you give it. Keep only safe types, such as images, mp4 and webm video, common audio, PDF and plain text. Re-type everything else to text/plain before you call URL.createObjectURL, including zip, Office and octet-stream.

Do not keep SVG in a blob: URL either, even for <img>. The reader can open the image in its own tab, and the SVG then runs as your site. Serve SVG from a separate site, or use a data:image/svg+xml URL, as Blok does.

Check a link before it is framed

Many sites refuse to be framed, and the reader then sees an empty frame. Your backend can tell first. Pass linkPaste.checkEmbed, or set server and Blok calls the server's GET /frame-check.

  • blocked or insecure inserts a bookmark instead, with a short notice.
  • allowed, unknown, an error, or no answer within 3 seconds inserts the embed.

The verdict is a hint for the interface, not a security check. It never lifts another rule. The browser still enforces the framed site's own X-Frame-Options and frame-ancestors.

What we measured

The popup and PDF results above come from runs on 3 November 2026, on a local test server. The browsers were Chrome 156 and Playwright's own builds of Firefox 146 and WebKit 26. Those two builds are patched, so stock Firefox and Safari may differ.

  • A frame sandboxed without allow-popups-to-escape-sandbox opened popups that kept the sandbox. They could not move the editor's tab in any browser.
  • With that token, the Firefox popup moved the editor's tab. same-origin-allow-popups did not stop it. With same-origin, the popup had no window.opener in all three browsers.
  • An HTML page framed with no sandbox ran its script and could move the editor's tab. A .pdf address proves nothing, because the server's Content-Type decides.
  • A text/html blob opened in a new tab ran as the page that made it, even with rel="noopener".
  • An image/svg+xml blob opened in a new tab ran its script as the page that made it, and read its storage. The same SVG as a data: URL showed in <img> in all three browsers. Opened in a tab, it did not open, or it ran with origin null and no storage.
  • Chrome blocks its PDF viewer in every sandboxed frame, sandbox="" included. That is why remote PDFs open in a new tab.