safe-content-frame 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ type SandboxOption = "allow-same-origin" | "allow-scripts" | "allow-forms" | "allow-popups" | "allow-modals" | "allow-downloads" | "allow-popups-to-escape-sandbox";
2
+ type ShadowDomOption = {
3
+ enabled: true;
4
+ } | {
5
+ enabled: false;
6
+ };
7
+ type PdfViewerOption = {
8
+ type: "native-with-download-fallback";
9
+ };
10
+ interface SafeContentFrameOptions {
11
+ useShadowDom?: ShadowDomOption;
12
+ enableBrowserCaching?: boolean;
13
+ sandbox?: SandboxOption[];
14
+ pdfViewer?: PdfViewerOption;
15
+ salt?: string;
16
+ }
17
+ interface RenderedFrame {
18
+ iframe: HTMLIFrameElement;
19
+ origin: string;
20
+ sendMessage(data: unknown, transfer?: Transferable[]): void;
21
+ fullyLoadedPromiseWithTimeout(timeoutMs: number): Promise<void>;
22
+ dispose(): void;
23
+ }
24
+ declare class SafeContentFrame {
25
+ private product;
26
+ private options;
27
+ private static readonly PRODUCT_HASH;
28
+ constructor(product: string, options?: SafeContentFrameOptions);
29
+ private createShimUrl;
30
+ private getSandboxAttribute;
31
+ private createIframe;
32
+ private renderContent;
33
+ renderHtml(html: string, container: HTMLElement): Promise<RenderedFrame>;
34
+ renderRaw(content: Uint8Array | string, mimeType: string, container: HTMLElement): Promise<RenderedFrame>;
35
+ renderPdf(content: Uint8Array, container: HTMLElement, fragment?: string): Promise<RenderedFrame>;
36
+ }
37
+
38
+ export { type PdfViewerOption, type RenderedFrame, SafeContentFrame, type SafeContentFrameOptions, type SandboxOption, type ShadowDomOption };
package/dist/index.js ADDED
@@ -0,0 +1,152 @@
1
+ // src/index.ts
2
+ var SCF_HOST = "scf.usercontent.goog";
3
+ var SHIM_PATH = "shim.html";
4
+ async function computeOriginHash(product, salt, origin) {
5
+ const input = `${product}$@#|${salt}$@#|${origin}`;
6
+ const encoder = new TextEncoder();
7
+ const data = encoder.encode(input);
8
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
9
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
10
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
11
+ const hashBigInt = BigInt("0x" + hashHex);
12
+ return hashBigInt.toString(36).slice(0, 50);
13
+ }
14
+ function generateRandomSalt() {
15
+ const array = new Uint8Array(32);
16
+ crypto.getRandomValues(array);
17
+ return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
18
+ }
19
+ async function computeContentHash(content) {
20
+ const data = typeof content === "string" ? new TextEncoder().encode(content) : content;
21
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data.buffer);
22
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
23
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
24
+ const hashBigInt = BigInt("0x" + hashHex);
25
+ return hashBigInt.toString(36).slice(0, 32);
26
+ }
27
+ var _SafeContentFrame = class _SafeContentFrame {
28
+ constructor(product, options = {}) {
29
+ this.product = product;
30
+ this.options = {
31
+ sandbox: ["allow-same-origin", "allow-scripts"],
32
+ ...options
33
+ };
34
+ }
35
+ async createShimUrl(content) {
36
+ const parentOrigin = window.location.origin;
37
+ let salt;
38
+ if (this.options.salt) {
39
+ salt = this.options.salt;
40
+ } else if (this.options.enableBrowserCaching) {
41
+ const contentHash = await computeContentHash(content);
42
+ salt = contentHash + window.location.pathname;
43
+ } else {
44
+ salt = generateRandomSalt();
45
+ }
46
+ const originHash = await computeOriginHash(this.product, salt, parentOrigin);
47
+ const url = new URL(
48
+ `https://${originHash}-${_SafeContentFrame.PRODUCT_HASH}.${SCF_HOST}/${this.product}/${SHIM_PATH}`
49
+ );
50
+ url.searchParams.set("origin", parentOrigin);
51
+ if (this.options.enableBrowserCaching) {
52
+ url.searchParams.set("cache", "1");
53
+ }
54
+ return url.toString();
55
+ }
56
+ getSandboxAttribute() {
57
+ const sandbox = this.options.sandbox || ["allow-same-origin", "allow-scripts"];
58
+ const required = /* @__PURE__ */ new Set(["allow-same-origin", "allow-scripts"]);
59
+ const all = /* @__PURE__ */ new Set([...sandbox, ...required]);
60
+ return Array.from(all).join(" ");
61
+ }
62
+ createIframe(container) {
63
+ const iframe = document.createElement("iframe");
64
+ iframe.setAttribute("sandbox", this.getSandboxAttribute());
65
+ iframe.style.border = "none";
66
+ iframe.style.width = "100%";
67
+ iframe.style.height = "100%";
68
+ if (this.options.useShadowDom?.enabled) {
69
+ const shadowHost = document.createElement("div");
70
+ const shadow = shadowHost.attachShadow({ mode: "closed" });
71
+ shadow.appendChild(iframe);
72
+ container.appendChild(shadowHost);
73
+ } else {
74
+ container.appendChild(iframe);
75
+ }
76
+ return iframe;
77
+ }
78
+ async renderContent(content, mimeType, container) {
79
+ const shimUrl = await this.createShimUrl(content);
80
+ const iframe = this.createIframe(container);
81
+ const iframeOrigin = new URL(shimUrl).origin;
82
+ return new Promise((resolve, reject) => {
83
+ const channel = new MessageChannel();
84
+ const contentData = typeof content === "string" ? new TextEncoder().encode(content) : content;
85
+ const handleShimReady = (event) => {
86
+ if (event.origin !== iframeOrigin) return;
87
+ if (event.data?.type !== "shim-ready") return;
88
+ window.removeEventListener("message", handleShimReady);
89
+ iframe.contentWindow?.postMessage(
90
+ {
91
+ type: "render",
92
+ body: contentData,
93
+ mimeType,
94
+ salt: this.options.salt || ""
95
+ },
96
+ iframeOrigin,
97
+ [channel.port2]
98
+ );
99
+ };
100
+ window.addEventListener("message", handleShimReady);
101
+ let loadResolve;
102
+ const fullyLoadedPromise = new Promise((res) => {
103
+ loadResolve = res;
104
+ });
105
+ channel.port1.onmessage = (e) => {
106
+ if (e.data === "Reloading iframe" || e.data?.type === "loaded") {
107
+ loadResolve();
108
+ }
109
+ };
110
+ iframe.src = shimUrl;
111
+ const renderedFrame = {
112
+ iframe,
113
+ origin: iframeOrigin,
114
+ sendMessage(data, transfer) {
115
+ iframe.contentWindow?.postMessage(data, iframeOrigin, transfer);
116
+ },
117
+ fullyLoadedPromiseWithTimeout(timeoutMs) {
118
+ return Promise.race([
119
+ fullyLoadedPromise,
120
+ new Promise(
121
+ (_, rej) => setTimeout(() => rej(new Error("Timeout waiting for iframe to load")), timeoutMs)
122
+ )
123
+ ]);
124
+ },
125
+ dispose() {
126
+ iframe.remove();
127
+ }
128
+ };
129
+ iframe.onload = () => {
130
+ resolve(renderedFrame);
131
+ };
132
+ iframe.onerror = () => {
133
+ reject(new Error("Failed to load iframe"));
134
+ };
135
+ });
136
+ }
137
+ async renderHtml(html, container) {
138
+ return this.renderContent(html, "text/html; charset=utf-8", container);
139
+ }
140
+ async renderRaw(content, mimeType, container) {
141
+ return this.renderContent(content, mimeType, container);
142
+ }
143
+ async renderPdf(content, container, fragment) {
144
+ const mimeType = fragment ? `application/pdf#${fragment}` : "application/pdf";
145
+ return this.renderContent(content, mimeType, container);
146
+ }
147
+ };
148
+ _SafeContentFrame.PRODUCT_HASH = "h833788197";
149
+ var SafeContentFrame = _SafeContentFrame;
150
+ export {
151
+ SafeContentFrame
152
+ };
@@ -0,0 +1,5 @@
1
+ import { PdfViewerOption } from './index.js';
2
+
3
+ declare function nativePdfViewerWithDownloadFallback(): PdfViewerOption;
4
+
5
+ export { nativePdfViewerWithDownloadFallback };
@@ -0,0 +1,7 @@
1
+ // src/pdf_rendering.ts
2
+ function nativePdfViewerWithDownloadFallback() {
3
+ return { type: "native-with-download-fallback" };
4
+ }
5
+ export {
6
+ nativePdfViewerWithDownloadFallback
7
+ };
@@ -0,0 +1,6 @@
1
+ import { ShadowDomOption } from './index.js';
2
+
3
+ declare function enableShadowDom(): ShadowDomOption;
4
+ declare function unsafeDisableShadowDom(): ShadowDomOption;
5
+
6
+ export { enableShadowDom, unsafeDisableShadowDom };
@@ -0,0 +1,11 @@
1
+ // src/shadow_dom.ts
2
+ function enableShadowDom() {
3
+ return { enabled: true };
4
+ }
5
+ function unsafeDisableShadowDom() {
6
+ return { enabled: false };
7
+ }
8
+ export {
9
+ enableShadowDom,
10
+ unsafeDisableShadowDom
11
+ };
package/package.json CHANGED
@@ -1,5 +1,34 @@
1
1
  {
2
2
  "name": "safe-content-frame",
3
- "version": "0.0.0",
4
- "description": ""
3
+ "version": "0.0.1",
4
+ "description": "Secure iframe rendering for untrusted content",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./shadow_dom": {
13
+ "types": "./dist/shadow_dom.d.ts",
14
+ "default": "./dist/shadow_dom.js"
15
+ },
16
+ "./pdf_rendering": {
17
+ "types": "./dist/pdf_rendering.d.ts",
18
+ "default": "./dist/pdf_rendering.js"
19
+ }
20
+ },
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "files": [
24
+ "dist",
25
+ "README.md"
26
+ ],
27
+ "devDependencies": {
28
+ "tsup": "^8.0.0",
29
+ "typescript": "^5.0.0"
30
+ },
31
+ "scripts": {
32
+ "build": "tsup src/index.ts src/shadow_dom.ts src/pdf_rendering.ts --format esm --dts"
33
+ }
5
34
  }