This project is in alpha — APIs may change without notice.
native-window

Security

Security model and best practices for native-window

Threat Model

The webview is an untrusted execution context. Even if you control the HTML, the boundary between your host process (Bun/Node.js) and the webview (browser engine) is a trust boundary — similar to an iframe from a different origin. A compromised or malicious webview page can:

  • Send arbitrary messages via window.ipc.postMessage()
  • Send messages with unexpected payload shapes (TypeScript types are erased at runtime)
  • Attempt to exploit loadHtml() or unsafe.evaluateJs() if user input is interpolated

Your host-side code should treat all data from the webview as untrusted input and validate it before acting on it.

Injection Risks

HTML Injection via loadHtml()

If you interpolate user input into an HTML string passed to loadHtml(), an attacker can inject scripts:

// DANGEROUS: user input is interpolated directly
const userInput = '<img onerror="alert(1)" src=x>';
win.loadHtml(`<p>${userInput}</p>`);

Mitigation: Sanitize untrusted content using a dedicated library such as DOMPurify or sanitize-html:

import { NativeWindow } from "@fcannizzaro/native-window";
import DOMPurify from "dompurify";

const userInput = '<img onerror="alert(1)" src=x>';
win.loadHtml(`<p>${DOMPurify.sanitize(userInput)}</p>`);

Script Injection via unsafe.evaluateJs()

If you interpolate user input into a script passed to unsafe.evaluateJs(), an attacker can execute arbitrary code in the webview:

// DANGEROUS: user input is interpolated directly
const userInput = '"; document.cookie; "';
win.unsafe.evaluateJs(`display("${userInput}")`);

Mitigation: Use sanitizeForJs() to escape strings:

import { NativeWindow, sanitizeForJs } from "@fcannizzaro/native-window";

const userInput = '"; document.cookie; "';
win.unsafe.evaluateJs(`display("${sanitizeForJs(userInput)}")`);
// The input is safely escaped as a string literal

sanitizeForJs() handles backslashes, quotes, newlines, null bytes, closing </script> tags, and Unicode line/paragraph separators (U+2028, U+2029).

IPC Security

Types Are Erased at Runtime

TypeScript types provide compile-time safety on the host side, but they are erased at runtime. The webview can send any payload shape:

// Your schema says counter is a number...
const schemas = { counter: z.number() };

// ...but the webview can send anything
__channel__.send("counter", "not a number");
__channel__.send("counter", { malicious: true });

Schema validation catches these mismatches at runtime. Schemas are required when creating channels — every incoming payload is validated automatically.

Schema Validation

Schemas are required on both host and client sides. They provide both TypeScript types and runtime validation:

import { z } from "zod";
import { createChannel } from "@fcannizzaro/native-window-ipc";

const ch = createChannel(win, {
  schemas: {
    "user-click": z.object({ x: z.number(), y: z.number() }),
    counter: z.number().finite(),
    message: z.string().max(1024),
  },
  onValidationError: (type, payload) => {
    console.warn(`Rejected invalid "${type}" payload:`, payload);
  },
});

Invalid payloads are dropped before reaching your handlers. If onValidationError is not set, rejections are silent.

The IPC package supports any schema library implementing the safeParse() interface, including Zod, Valibot, and libraries following the Standard Schema spec.

The Raw Bridge Is Always Available

The low-level IPC bridge (window.ipc.postMessage()) is always available in the webview, regardless of whether typed channels are used. A compromised page can bypass the typed channel and send raw messages directly:

// Bypasses the typed channel entirely
window.ipc.postMessage('arbitrary string');
window.ipc.postMessage(JSON.stringify({ $ch: "counter", p: "not a number" }));

This means:

  • Typed channels do not replace validation — they provide ergonomic type safety for development, not a security boundary
  • Schema validation catches malformed payloads — all incoming messages are validated against the schemas before reaching your handlers
  • Non-envelope messages are ignored — the typed channel only dispatches messages matching the { $ch, p } envelope format

Origin Control

Trusted Origins

When loading external URLs, you can restrict which pages receive the IPC client script using trustedOrigins:

const ch = createChannel(win, {
  schemas: { ping: z.string() },
  trustedOrigins: ["https://myapp.com"],
});

This prevents the typed IPC client from being injected on untrusted pages after navigation. The initial injection always runs (before any page has loaded). On subsequent navigations, the client script is only re-injected if the page URL's origin matches.

How It Works

The trustedOrigins option controls two things:

  1. Client injection — the typed IPC client (window.__channel__) is only injected on pages whose origin matches
  2. Message filtering — incoming messages are checked against the source page URL origin and dropped if it does not match

The native onMessage callback receives both the message string and the source page URL, which the IPC layer uses for origin-based filtering.

Limitations

  • Raw bridge is unrestrictedtrustedOrigins filters typed channel messages, but the raw window.ipc.postMessage() bridge is always available regardless of origin
  • Defense-in-depth — combine trustedOrigins with schema validation for the best protection

Best Practices

  • Always validate incoming IPC payloads at runtime — schemas are required when creating channels, ensuring all payloads are validated automatically. Never trust TypeScript types alone for data from the webview.
  • Use schemas for all events — schemas keep your type definitions and validation logic in sync, reducing the chance of drift.
  • Use a sanitization library for user content in HTML — prevent XSS when using loadHtml() with dynamic content. Use DOMPurify or sanitize-html.
  • Use sanitizeForJs() for user content in scripts — prevent injection when using win.unsafe.evaluateJs() with dynamic strings.
  • Set trustedOrigins when loading external URLs — prevent the IPC client from being injected on untrusted pages.
  • Treat the webview as an untrusted client — apply the same input validation and sanitization you would for any client-server boundary.
  • Set devtools: false in production — disable browser devtools to reduce the attack surface.
  • Limit message size — validate string lengths and object sizes to prevent denial-of-service via oversized payloads.

On this page