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()orunsafe.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 literalsanitizeForJs() 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:
- Client injection — the typed IPC client (
window.__channel__) is only injected on pages whose origin matches - 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 unrestricted —
trustedOriginsfilters typed channel messages, but the rawwindow.ipc.postMessage()bridge is always available regardless of origin - Defense-in-depth — combine
trustedOriginswith 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 usingwin.unsafe.evaluateJs()with dynamic strings. - Set
trustedOriginswhen 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: falsein 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.