kandlo Docs

6. Security

Last updated · 7 min read

On this page

What protects an embedded chart, what you have to configure, and what the protocol does not do for you.

The recommended deployment has three origins:

Origin Role Trusts
https://app.example.com your app, the host page the chart's messages, after checking their origin
https://charts.example.com the chart page (static files) commands from the origins on its allow-list only
wss://feed.example.com the feed server connections from the chart page's origin

Checklist:

  • The chart page has its own origin, different from your app's (2).
  • AppData['embed-allowed-origins'] lists your app's origins, and only those (1).
  • The chart page sends Content-Security-Policy: frame-ancestors with the same origins (3).
  • Everything is served over https / wss.
  • The feed server refuses other origins and is protected according to what your data licence requires (4).

1. Who may drive the chart: the origin allow-list#

The chart page answers postMessage commands from one origin, decided when it starts. Configure the list in apps/web/index.html, after the inline var AppData = {...}; statement:

JavaScript
AppData['embed-allowed-origins'] = ['https://app.example.com', 'https://admin.example.com'];

With the list set:

  • the parentOrigin URL parameter can only pick an origin from the list; a value not on it disables the bridge;
  • where the browser reports the real parent origin (location.ancestorOrigins), it must match, so a page on another origin cannot borrow an allowed parentOrigin in its iframe URL;
  • in browsers that do not report the parent origin, the bridge still only sends to the chosen origin (targetOrigin is never '*') and only accepts messages whose origin is that origin and whose source is window.parent. A page on another origin can show the chart but cannot receive its replies or drive it.

Entries must be bare http(s) origins: https://app.example.com, no path, no trailing wildcard. Anything else is dropped from the list, so a typo can only make the list shorter, never wider.

The rules are in apps/web/src/interactions/embed-protocol.js (resolveParentOrigin, acceptsMessage) and covered by tests/embed-protocol.test.mjs.

On the host side#

@kandlo/react accepts a message only when event.source is its own iframe's window and event.origin is the chart's origin (taken from src), and posts with that origin as targetOrigin. If you write your own host code, do the same:

JavaScript
window.addEventListener('message', (e) => {
  if (e.source !== iframe.contentWindow || e.origin !== 'https://charts.example.com') return;
  // ...
});
iframe.contentWindow.postMessage(msg, 'https://charts.example.com');   // never '*'

Everything the bridge accepts is validated in the chart page: unknown commands, unknown fields and unknown indicator settings are refused with an error, string lengths and nesting are bounded, and at most 50 commands are held before the chart is ready. See API reference.

2. The iframe sandbox#

@kandlo/react sets sandbox="allow-scripts allow-same-origin" on the iframe (DEFAULT_SANDBOX):

  • allow-scripts: the chart is a script page.
  • allow-same-origin: the chart keeps its real origin. Without it the frame gets an opaque origin: localStorage and cookies throw, and every message arrives with origin "null", so your page could not tell the chart from any other sandboxed frame and the allow-list could not work.

Everything else stays blocked: the chart cannot navigate your page, open popups, submit forms, show alert/confirm dialogs or start downloads.

Why the chart needs its own origin. A frame with both allow-scripts and allow-same-origin that is on the same origin as its parent can reach into the parent and remove its own sandbox. Across origins it cannot, and the sandbox holds. Browsers print a console warning for this token combination ("An iframe which has both allow-scripts and allow-same-origin ... can escape its sandboxing") whatever the origins are; with the chart on its own origin the warning does not apply. Note that subdomains of one site (app.example.com, charts.example.com) are different origins.

Override with the sandbox prop or option (a string of tokens, or false to omit the attribute). Grant permissions the chart page needs only through allow (the iframe permissions policy); it needs none by default.

3. Who may frame the chart#

The allow-list decides who can drive the chart. Who can show it is decided by the chart page's response headers, in particular its Content Security Policy (CSP) header. Without them, any website can frame your chart page, showing your data under its own name.

Send this header with the chart page (index.html):

HTTP
Content-Security-Policy: frame-ancestors https://app.example.com
  • List the same origins as embed-allowed-origins. Add the origin of a mobile host page (Mobile apps (web views)) if you have one.
  • Do not send X-Frame-Options: DENY or SAMEORIGIN: they block your own app too. frame-ancestors replaces X-Frame-Options in current browsers.
  • npm run serve (apps/gateway/src/edge.js) is a development server and sends no such header. Set it on the host that serves dist/ in production.

Checked in Chromium: with frame-ancestors http://localhost:9138 the chart framed by that origin reached ready; with frame-ancestors https://app.example.com the same embed was blocked by the browser ("Framing ... violates the following Content Security Policy directive").

A content security policy for the chart page#

The chart page loads only its own scripts, styles, fonts and images, and connects only to the feed. This policy was tested on the chart page: it started, took commands, switched theme and layout and added an indicator with no violation reported:

HTTP
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'unsafe-inline' 'unsafe-eval';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: blob:;
  font-src 'self' data:;
  connect-src 'self' wss://feed.example.com;
  worker-src 'self' blob:;
  base-uri 'self';
  form-action 'none';
  object-src 'none';
  frame-ancestors https://app.example.com

(One header line in practice.) Two sources cannot be removed:

  • 'unsafe-inline' in script-src: index.html carries its configuration (AppData) and start-up code in inline scripts.
  • 'unsafe-eval': the WebGL renderer generates code at run time. Without it the chart does not start (the console shows Current environment does not allow unsafe-eval).

This policy applies to the chart page only; your app keeps its own.

4. The feed server#

The data-feed protocol has no user authentication. The chart's auth message carries a token the chart generates itself; treat it as a session label, not a credential. Whoever can open a websocket to your feed server can read everything it serves.

What to do, depending on how sensitive the data is:

  1. Check the Origin header. Browsers always send it on a websocket upgrade, and a web page cannot change it. The example server refuses other origins when FEED_ALLOWED_ORIGINS is set (allowRequest in examples/custom-feed/feed-adapter.mjs; tested in examples/custom-feed/test/feed.test.mjs). This stops other websites from using your feed through their visitors' browsers. It does not stop scripts outside a browser, which can send any Origin.

  2. Require your own session when the data is licensed per user or must not be public. The websocket handshake the chart page opens carries the cookies the browser holds for the feed's host, so the feed can check the session your app already has. createFeedServer takes an authorize hook for this; it runs on every connection, after the origin check:

    JavaScript
    await createFeedServer({
      source,
      allowedOrigins: ['https://charts.example.com'],
      // req is the websocket upgrade request (node:http IncomingMessage).
      authorize: async (req) => {
        const sid = /(?:^|;\s*)app_session=([^;]+)/.exec(req.headers.cookie || '')?.[1];
        return Boolean(sid && await sessions.isValid(sid));   // your session store
      },
    });

    For the cookie to reach the feed, put the app, the chart page and the feed on one site (app.example.com, charts.example.com, feed.example.com) and set the session cookie for that site (Domain=example.com, Secure, HttpOnly, SameSite=Lax). The chart frame is then same-site with its parent and the feed, so the browser sends the cookie. With the chart page or the feed on another site, the cookie is a third-party cookie and browsers increasingly block it; use a gateway in front of the feed instead.

    Mount the chart after the user has signed in. A refused connection is retried by the chart every few seconds, and the embed bridge keeps waiting: a connection admitted later starts the chart and reports ready. The component does not wait that long, though: at readyTimeout (45 s) onError reports timeout and the frame is removed. After a new sign-in, mount the chart again (for example by changing its React key).

    Checked: the hook is covered by examples/custom-feed/test/feed.test.mjs (missing, wrong and valid cookies, a throwing check). In Chromium, the React example on localhost:5373 framed the chart page on localhost:9000, whose feed on localhost:8443 required the cookie: without it every handshake was refused (43 in that run, the chart showed no data); with the cookie set by the host page, the frame's handshake carried it, was admitted, and the chart loaded 1001 candles. That run used one host name with three ports; the Domain=example.com cookie across subdomains was not run.

  3. Limit what one connection can ask for. The example accepts only known symbols and periods, caps a history page at 1000 candles and ignores anything else. Add rate limits if the feed is public.

  4. Use wss://. An https chart page cannot open ws:// anyway.

5. What the chart does not do#

  • It sends nothing to third parties: no analytics, no error reporting, no third-party script hosts (npm run validate runs scripts/check-no-telemetry.mjs to keep it that way). Its only network traffic is its own static files and the feed connection you configure. (One opt-in seam exists: if you define window.__analyticsEndpoint, the chart posts one event type there. Define nothing and nothing is sent.)
  • It keeps its own settings and session values in its origin's localStorage (theme, chart type, timeframe, panel mode and similar). In an embed, the chart type, timeframe and theme are not saved.
  • It does not store indicators or drawings on a server, and the feed protocol does not require it to.

Type to search all guides.