diff --git a/src/core/applications.ts b/src/core/applications.ts
index feb666c..ed06501 100644
--- a/src/core/applications.ts
+++ b/src/core/applications.ts
@@ -25,6 +25,7 @@ import {
type ActionConfig,
type Config,
type ConfigExtensionInfo,
+ type ExtensionType,
type SubscriptionConfig,
createConfigFileEffect,
createEnvFileEffect,
@@ -32,13 +33,13 @@ import {
getExtensionsFromConfigEffect,
} from "../services/config";
import { bundleExtensionEffect as bundleExtServiceEffect } from "../services/extension/bundler";
-import { publicHttpUrl } from "../services/public-url";
import { getUploadTargetEffect } from "../services/extension/presigned-url";
import {
scanBundleEffect,
scanExtensionEffect,
} from "../services/extension/security-scan";
import { uploadArtifactEffect } from "../services/extension/upload";
+import { publicHttpUrl } from "../services/public-url";
import { getFromKeychainEffect } from "./auth";
import type { Environment } from "./environment";
import type { ScanReport } from "./security/types";
@@ -130,7 +131,7 @@ export interface ReleaseUiExtension {
name: string;
handle: string;
source: string;
- type: string;
+ type: ExtensionType;
target?: string;
}
@@ -885,6 +886,7 @@ export function applicationReleaseEffect(
yield* Effect.fail(
new ValidationError({
message: `UI extension "${ext.name}" has ${ext.targets.length} targets, but only one target is supported per extension during release`,
+ userMessage: `UI extension "${ext.name}" has too many targets. Only one target is supported per extension during release.`,
}),
);
}
diff --git a/src/core/security/rules/README.md b/src/core/security/rules/README.md
index bd38172..11a3a74 100644
--- a/src/core/security/rules/README.md
+++ b/src/core/security/rules/README.md
@@ -17,6 +17,7 @@ This directory contains all security rules enforced by the GoDaddy CLI security
| [SEC009](#sec009-module-loading) | `block` | Dynamic Module Loading |
| [SEC010](#sec010-path-traversal) | `block` | Path Traversal |
| [SEC011](#sec011-suspicious-scripts) | `warn` | Suspicious Package Scripts |
+| [SEC012](#sec012-dom-escape-operation) | `block` | DOM Escape Operation |
## Rule Details
@@ -312,6 +313,66 @@ Remove suspicious commands or use legitimate build tools (tsc, webpack, etc.).
---
+### SEC012: DOM Escape Operation
+
+**File:** `SEC012-dom-escape.ts`
+**Severity:** `block`
+**Default:** Enabled
+
+Prevents Phase 1 checkout/embed UI extension source from accessing page-level DOM, storage, or navigation APIs outside the host-provided extension container.
+
+Phase 1 DOM bundle extensions are trusted in-page code and can access browser globals. They must render only inside the `container` passed to `mount()`. True DOM isolation is provided by the future Worker/SDK runtime, not by the Phase 1 DOM bundle runtime.
+
+**Detects:**
+- Page DOM access: `document.body`, `document.documentElement`, `document.head`, `document.cookie`, `window.document`, `globalThis.document`
+- Page DOM querying/writing: `document.write()`, `document.querySelector()`, `document.querySelectorAll()`, `document.getElementById()`, `document.getElementsBy*()`
+- Page DOM creation/evaluation: `document.createElement()`, `document.createRange()`, `document.evaluate()`
+- Navigation APIs: `window.location`, `location.href`, `location.assign()`, `location.replace()`, `window.open()`, `open()`, `history.pushState()`, `history.replaceState()`
+- Frame/window escape APIs: `top.document`, `top.location`, `parent.document`, `parent.location`
+- Page-global storage: `localStorage`, `sessionStorage`
+- Prototype mutation: `Element.prototype`, `Node.prototype`
+- Container escape paths: `container.ownerDocument`, `container.parentElement`, `container.parentNode`, `container.closest()`
+
+**Example Violations:**
+```typescript
+document.body.innerHTML = ''; // BLOCKED
+document.querySelector('#checkout-root')?.remove(); // BLOCKED
+window.location.href = 'https://example.com'; // BLOCKED
+window.open('https://example.com'); // BLOCKED
+localStorage.setItem('token', value); // BLOCKED
+Element.prototype.remove = function () {}; // BLOCKED
+container.ownerDocument.body.innerHTML = ''; // BLOCKED
+container.closest('#checkout-root')?.remove(); // BLOCKED
+```
+
+**Allowed Pattern:**
+```typescript
+export function mount({ container }) {
+ container.innerHTML = '
Extension UI
';
+}
+```
+
+**Remediation:**
+Render only inside the host-provided `container`. Do not query, mutate, navigate, or otherwise interact with checkout page DOM directly.
+
+---
+
+## Bundle Rules
+
+Post-bundle scanning runs against the deployable artifact after bundling. These checks are defense-in-depth for generated output, dependencies, custom build steps, or older templates.
+
+### SEC112: DOM Escape Operation in Bundle
+
+**File:** `bundle/SEC112-dom-escape.ts`
+**Severity:** `block`
+**Source Rule:** `SEC012`
+
+Bundle-level counterpart to `SEC012`. Blocks deployable checkout/embed UI extension artifacts that contain obvious page-level DOM, storage, or navigation escape APIs.
+
+The bundle rule intentionally focuses on high-risk direct patterns that should not be present in Phase 1 UI extension bundles. It is not a sandbox; it complements source scanning and the runtime container boundary.
+
+---
+
## Rule Implementation
All rules follow this structure:
@@ -405,5 +466,5 @@ Target: <1ms per rule per file
---
-**Last Updated**: 2025-01-27
-**Rule Count**: 11 (SEC001-SEC011)
+**Last Updated**: 2026-06-23
+**Rule Count**: 12 source/package rules (SEC001-SEC012), plus bundle rules through SEC112
diff --git a/src/core/security/rules/SEC012-dom-escape.ts b/src/core/security/rules/SEC012-dom-escape.ts
new file mode 100644
index 0000000..f3dbad7
--- /dev/null
+++ b/src/core/security/rules/SEC012-dom-escape.ts
@@ -0,0 +1,593 @@
+import ts from "typescript";
+import type { Rule } from "../types.ts";
+
+type StaticMemberAccess =
+ | ts.PropertyAccessExpression
+ | ts.ElementAccessExpression;
+
+interface AliasInfo {
+ rootName: string;
+ declarationName: ts.Identifier;
+}
+
+const BLOCKED_GLOBAL_PROPERTIES: ReadonlyArray<[string, string]> = [
+ ["document", "body"],
+ ["document", "documentElement"],
+ ["document", "head"],
+ ["document", "forms"],
+ ["document", "images"],
+ ["document", "links"],
+ ["document", "scripts"],
+ ["document", "cookie"],
+ ["document", "activeElement"],
+ ["document", "children"],
+ ["document", "firstElementChild"],
+ ["window", "document"],
+ ["window", "location"],
+ ["globalThis", "document"],
+ ["globalThis", "location"],
+ ["location", "href"],
+ ["location", "assign"],
+ ["location", "replace"],
+ ["history", "pushState"],
+ ["history", "replaceState"],
+ ["top", "document"],
+ ["top", "location"],
+ ["parent", "document"],
+ ["parent", "location"],
+ ["Element", "prototype"],
+ ["Node", "prototype"],
+ ["container", "ownerDocument"],
+ ["container", "parentElement"],
+ ["container", "parentNode"],
+ ["container", "closest"],
+];
+
+const BLOCKED_GLOBAL_CALLS: ReadonlyArray<[string, string]> = [
+ ["document", "write"],
+ ["document", "querySelector"],
+ ["document", "querySelectorAll"],
+ ["document", "getElementById"],
+ ["document", "getElementsByClassName"],
+ ["document", "getElementsByName"],
+ ["document", "getElementsByTagName"],
+ ["document", "getElementsByTagNameNS"],
+ ["document", "createElement"],
+ ["document", "createRange"],
+ ["document", "evaluate"],
+ ["window", "open"],
+ ["location", "assign"],
+ ["location", "replace"],
+ ["history", "pushState"],
+ ["history", "replaceState"],
+ ["container", "closest"],
+];
+
+const BLOCKED_NESTED_CALLS: ReadonlyArray<[string, string, string]> = [
+ ["window", "document", "querySelector"],
+ ["window", "document", "querySelectorAll"],
+ ["window", "document", "getElementById"],
+ ["window", "document", "getElementsByClassName"],
+ ["window", "document", "getElementsByName"],
+ ["window", "document", "getElementsByTagName"],
+ ["window", "document", "getElementsByTagNameNS"],
+ ["window", "document", "write"],
+ ["window", "location", "assign"],
+ ["window", "location", "replace"],
+ ["globalThis", "document", "querySelector"],
+ ["globalThis", "document", "querySelectorAll"],
+ ["globalThis", "document", "getElementById"],
+ ["globalThis", "document", "write"],
+ ["globalThis", "location", "assign"],
+ ["globalThis", "location", "replace"],
+ ["top", "document", "querySelector"],
+ ["top", "document", "querySelectorAll"],
+ ["top", "document", "getElementById"],
+ ["parent", "document", "querySelector"],
+ ["parent", "document", "querySelectorAll"],
+ ["parent", "document", "getElementById"],
+];
+
+const STORAGE_ROOTS = new Set(["localStorage", "sessionStorage"]);
+
+const BLOCKED_GLOBAL_FUNCTIONS = new Set(["open"]);
+
+const ALIASABLE_GLOBAL_ROOTS = new Set([
+ "window",
+ "globalThis",
+ "document",
+ "location",
+ "history",
+ "top",
+ "parent",
+ "Element",
+ "Node",
+ "container",
+ "localStorage",
+ "sessionStorage",
+ "open",
+]);
+
+const DOCUMENT_OWNER_ROOTS = new Set(["window", "globalThis", "top", "parent"]);
+
+function isStaticMemberAccess(node: ts.Node): node is StaticMemberAccess {
+ return (
+ ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)
+ );
+}
+
+function getStaticMemberName(node: StaticMemberAccess): string | undefined {
+ if (ts.isPropertyAccessExpression(node)) {
+ return node.name.text;
+ }
+
+ const { argumentExpression } = node;
+ if (
+ ts.isStringLiteral(argumentExpression) ||
+ ts.isNoSubstitutionTemplateLiteral(argumentExpression)
+ ) {
+ return argumentExpression.text;
+ }
+
+ return undefined;
+}
+
+function bindingNameContains(
+ name: ts.BindingName,
+ targetName: string,
+): boolean {
+ if (ts.isIdentifier(name)) {
+ return name.text === targetName;
+ }
+
+ return name.elements.some(
+ (element) =>
+ ts.isBindingElement(element) &&
+ bindingNameContains(element.name, targetName),
+ );
+}
+
+function nodeContains(parent: ts.Node, child: ts.Node): boolean {
+ let current: ts.Node | undefined = child;
+ while (current) {
+ if (current === parent) {
+ return true;
+ }
+ current = current.parent;
+ }
+ return false;
+}
+
+function getBindingElementPropertyName(
+ element: ts.BindingElement,
+): string | undefined {
+ const { propertyName, name } = element;
+
+ if (propertyName) {
+ if (
+ ts.isIdentifier(propertyName) ||
+ ts.isStringLiteral(propertyName) ||
+ ts.isNumericLiteral(propertyName)
+ ) {
+ return propertyName.text;
+ }
+ return undefined;
+ }
+
+ return ts.isIdentifier(name) ? name.text : undefined;
+}
+
+function isIdentifierDeclarationName(node: ts.Identifier): boolean {
+ const { parent } = node;
+
+ return (
+ (ts.isVariableDeclaration(parent) && parent.name === node) ||
+ (ts.isParameter(parent) && parent.name === node) ||
+ (ts.isBindingElement(parent) && parent.name === node) ||
+ (ts.isFunctionDeclaration(parent) && parent.name === node) ||
+ (ts.isFunctionExpression(parent) && parent.name === node) ||
+ (ts.isClassDeclaration(parent) && parent.name === node) ||
+ (ts.isClassExpression(parent) && parent.name === node) ||
+ (ts.isImportClause(parent) && parent.name === node) ||
+ (ts.isNamespaceImport(parent) && parent.name === node) ||
+ (ts.isImportSpecifier(parent) && parent.name === node) ||
+ (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
+ (ts.isPropertyAssignment(parent) && parent.name === node)
+ );
+}
+
+function importClauseDeclaresName(
+ importClause: ts.ImportClause,
+ targetName: string,
+): boolean {
+ if (importClause.name?.text === targetName) {
+ return true;
+ }
+
+ const { namedBindings } = importClause;
+ if (!namedBindings) {
+ return false;
+ }
+
+ if (ts.isNamespaceImport(namedBindings)) {
+ return namedBindings.name.text === targetName;
+ }
+
+ return namedBindings.elements.some(
+ (element) => element.name.text === targetName,
+ );
+}
+
+function statementDeclaresName(
+ statement: ts.Statement,
+ targetName: string,
+ reference: ts.Node,
+ ignoredDeclaration?: ts.Node,
+): boolean {
+ if (ts.isVariableStatement(statement)) {
+ return statement.declarationList.declarations.some(
+ (declaration) =>
+ declaration.name !== ignoredDeclaration &&
+ bindingNameContains(declaration.name, targetName) &&
+ !nodeContains(declaration.name, reference),
+ );
+ }
+
+ if (
+ ts.isFunctionDeclaration(statement) &&
+ statement.name?.text === targetName &&
+ statement.name !== ignoredDeclaration &&
+ !nodeContains(statement.name, reference)
+ ) {
+ return true;
+ }
+
+ if (
+ ts.isClassDeclaration(statement) &&
+ statement.name?.text === targetName &&
+ statement.name !== ignoredDeclaration &&
+ !nodeContains(statement.name, reference)
+ ) {
+ return true;
+ }
+
+ if (ts.isImportDeclaration(statement) && statement.importClause) {
+ return importClauseDeclaresName(statement.importClause, targetName);
+ }
+
+ return false;
+}
+
+function scopeDeclaresName(
+ scope: ts.Node,
+ targetName: string,
+ reference: ts.Node,
+ ignoredDeclaration?: ts.Node,
+): boolean {
+ if (
+ ts.isFunctionDeclaration(scope) ||
+ ts.isFunctionExpression(scope) ||
+ ts.isArrowFunction(scope) ||
+ ts.isMethodDeclaration(scope) ||
+ ts.isConstructorDeclaration(scope) ||
+ ts.isGetAccessor(scope) ||
+ ts.isSetAccessor(scope)
+ ) {
+ if (
+ scope.parameters.some(
+ (parameter) =>
+ parameter.name !== ignoredDeclaration &&
+ bindingNameContains(parameter.name, targetName) &&
+ !nodeContains(parameter.name, reference),
+ )
+ ) {
+ return true;
+ }
+ }
+
+ if (ts.isSourceFile(scope) || ts.isBlock(scope) || ts.isModuleBlock(scope)) {
+ return scope.statements.some((statement) =>
+ statementDeclaresName(
+ statement,
+ targetName,
+ reference,
+ ignoredDeclaration,
+ ),
+ );
+ }
+
+ return false;
+}
+
+function isShadowedGlobalReference(
+ identifier: ts.Identifier,
+ ignoredDeclaration?: ts.Node,
+): boolean {
+ let current: ts.Node | undefined = identifier.parent;
+ while (current) {
+ if (
+ scopeDeclaresName(
+ current,
+ identifier.text,
+ identifier,
+ ignoredDeclaration,
+ )
+ ) {
+ return true;
+ }
+ current = current.parent;
+ }
+
+ return false;
+}
+
+function isMemberObjectIdentifier(node: ts.Identifier): boolean {
+ const { parent } = node;
+ return (
+ (ts.isPropertyAccessExpression(parent) && parent.expression === node) ||
+ (ts.isElementAccessExpression(parent) && parent.expression === node)
+ );
+}
+
+function resolveIdentifierRoot(
+ node: ts.Identifier,
+ aliases: ReadonlyMap,
+): string | undefined {
+ if (isIdentifierDeclarationName(node)) {
+ return undefined;
+ }
+
+ const aliasInfo = aliases.get(node.text);
+ if (aliasInfo) {
+ return isShadowedGlobalReference(node, aliasInfo.declarationName)
+ ? undefined
+ : aliasInfo.rootName;
+ }
+
+ if (node.text === "container") {
+ return "container";
+ }
+
+ if (isShadowedGlobalReference(node)) {
+ return undefined;
+ }
+
+ return node.text;
+}
+
+function resolveExpressionRoot(
+ node: ts.Node,
+ aliases: ReadonlyMap,
+): string | undefined {
+ if (ts.isIdentifier(node)) {
+ return resolveIdentifierRoot(node, aliases);
+ }
+
+ if (!isStaticMemberAccess(node)) {
+ return undefined;
+ }
+
+ const memberName = getStaticMemberName(node);
+ const ownerRoot = resolveExpressionRoot(node.expression, aliases);
+
+ if (memberName === "document" && DOCUMENT_OWNER_ROOTS.has(ownerRoot ?? "")) {
+ return "document";
+ }
+
+ if (memberName === "location" && DOCUMENT_OWNER_ROOTS.has(ownerRoot ?? "")) {
+ return "location";
+ }
+
+ return undefined;
+}
+
+function isMemberAccess(
+ node: ts.Node,
+ objectName: string,
+ propertyName: string,
+ aliases: ReadonlyMap,
+): boolean {
+ return (
+ isStaticMemberAccess(node) &&
+ getStaticMemberName(node) === propertyName &&
+ resolveExpressionRoot(node.expression, aliases) === objectName
+ );
+}
+
+function isNestedMemberAccess(
+ node: ts.Node,
+ objectName: string,
+ firstPropertyName: string,
+ secondPropertyName: string,
+ aliases: ReadonlyMap,
+): boolean {
+ if (
+ !isStaticMemberAccess(node) ||
+ getStaticMemberName(node) !== secondPropertyName
+ ) {
+ return false;
+ }
+
+ return isMemberAccess(
+ node.expression,
+ objectName,
+ firstPropertyName,
+ aliases,
+ );
+}
+
+function isBlockedPropertyAccess(
+ node: ts.Node,
+ aliases: ReadonlyMap,
+): boolean {
+ if (!isStaticMemberAccess(node)) {
+ return false;
+ }
+
+ const objectRoot = resolveExpressionRoot(node.expression, aliases);
+ if (STORAGE_ROOTS.has(objectRoot ?? "")) {
+ return true;
+ }
+
+ return BLOCKED_GLOBAL_PROPERTIES.some(([objectName, propertyName]) =>
+ isMemberAccess(node, objectName, propertyName, aliases),
+ );
+}
+
+function isBlockedCallExpression(
+ node: ts.CallExpression,
+ aliases: ReadonlyMap,
+): boolean {
+ const { expression } = node;
+
+ if (ts.isIdentifier(expression)) {
+ const rootName = resolveIdentifierRoot(expression, aliases);
+ if (rootName && BLOCKED_GLOBAL_FUNCTIONS.has(rootName)) {
+ return true;
+ }
+ }
+
+ return (
+ BLOCKED_GLOBAL_CALLS.some(([objectName, propertyName]) =>
+ isMemberAccess(expression, objectName, propertyName, aliases),
+ ) ||
+ BLOCKED_NESTED_CALLS.some(
+ ([objectName, firstPropertyName, secondPropertyName]) =>
+ isNestedMemberAccess(
+ expression,
+ objectName,
+ firstPropertyName,
+ secondPropertyName,
+ aliases,
+ ),
+ )
+ );
+}
+
+function isBlockedStorageIdentifier(
+ node: ts.Node,
+ aliases: ReadonlyMap,
+): boolean {
+ if (!ts.isIdentifier(node) || isMemberObjectIdentifier(node)) {
+ return false;
+ }
+
+ const rootName = resolveIdentifierRoot(node, aliases);
+ return STORAGE_ROOTS.has(rootName ?? "");
+}
+
+function isBlockedDestructuringProperty(
+ rootName: string,
+ propertyName: string,
+): boolean {
+ return (
+ BLOCKED_GLOBAL_PROPERTIES.some(
+ ([objectName, blockedPropertyName]) =>
+ objectName === rootName && blockedPropertyName === propertyName,
+ ) ||
+ BLOCKED_GLOBAL_CALLS.some(
+ ([objectName, blockedPropertyName]) =>
+ objectName === rootName && blockedPropertyName === propertyName,
+ )
+ );
+}
+
+function destructuresBlockedProperty(
+ bindingPattern: ts.ObjectBindingPattern,
+ rootName: string,
+): boolean {
+ return bindingPattern.elements.some((element) => {
+ const propertyName = getBindingElementPropertyName(element);
+ return propertyName
+ ? isBlockedDestructuringProperty(rootName, propertyName)
+ : false;
+ });
+}
+
+/**
+ * SEC012: DOM escape operation in UI extension source.
+ *
+ * Phase 1 DOM bundle extensions must render only inside the host-provided
+ * container. This rule blocks obvious page-level DOM and navigation access.
+ */
+export const SEC012: Rule = {
+ meta: {
+ id: "SEC012",
+ defaultSeverity: "block",
+ title: "DOM escape operation in UI extension source",
+ description:
+ "Blocks page-level DOM, storage, or navigation APIs that can escape the host-provided UI extension container",
+ remediation:
+ "Render only inside the container passed to mount(). Do not query or mutate checkout page DOM directly.",
+ },
+ create: (ctx) => {
+ const aliases = new Map();
+
+ return {
+ [ts.SyntaxKind.VariableDeclaration]: (node: ts.Node) => {
+ if (!ts.isVariableDeclaration(node) || !node.initializer) {
+ return;
+ }
+
+ const rootName = resolveExpressionRoot(node.initializer, aliases);
+ if (
+ rootName &&
+ ts.isObjectBindingPattern(node.name) &&
+ destructuresBlockedProperty(node.name, rootName)
+ ) {
+ ctx.report(
+ "Blocked: UI extensions must not destructure page-level DOM, storage, or navigation APIs outside the host-provided container.",
+ node,
+ );
+ return;
+ }
+
+ if (!ts.isIdentifier(node.name)) {
+ return;
+ }
+
+ if (rootName && ALIASABLE_GLOBAL_ROOTS.has(rootName)) {
+ aliases.set(node.name.text, {
+ rootName,
+ declarationName: node.name,
+ });
+ }
+ },
+ [ts.SyntaxKind.Identifier]: (node: ts.Node) => {
+ if (isBlockedStorageIdentifier(node, aliases)) {
+ ctx.report(
+ "Blocked: UI extensions must not access page-global browser storage.",
+ node,
+ );
+ }
+ },
+ [ts.SyntaxKind.PropertyAccessExpression]: (node: ts.Node) => {
+ if (isBlockedPropertyAccess(node, aliases)) {
+ ctx.report(
+ "Blocked: UI extensions must render only inside the host-provided container and must not access page-level DOM, storage, or navigation APIs.",
+ node,
+ );
+ }
+ },
+ [ts.SyntaxKind.ElementAccessExpression]: (node: ts.Node) => {
+ if (isBlockedPropertyAccess(node, aliases)) {
+ ctx.report(
+ "Blocked: UI extensions must render only inside the host-provided container and must not access page-level DOM, storage, or navigation APIs.",
+ node,
+ );
+ }
+ },
+ [ts.SyntaxKind.CallExpression]: (node: ts.Node) => {
+ if (
+ ts.isCallExpression(node) &&
+ isBlockedCallExpression(node, aliases)
+ ) {
+ ctx.report(
+ "Blocked: UI extensions must render only inside the host-provided container and must not query, write, navigate, or escape checkout page DOM directly.",
+ node,
+ );
+ }
+ },
+ };
+ },
+};
diff --git a/src/core/security/rules/bundle/SEC112-dom-escape.ts b/src/core/security/rules/bundle/SEC112-dom-escape.ts
new file mode 100644
index 0000000..1970cc9
--- /dev/null
+++ b/src/core/security/rules/bundle/SEC112-dom-escape.ts
@@ -0,0 +1,74 @@
+import type { BundleRule } from "../../types.ts";
+
+/**
+ * SEC112: DOM escape operations in checkout/embed UI extension bundles.
+ *
+ * Phase 1 DOM bundle extensions are trusted in-page code and must render only
+ * inside the host-provided container. Start strict: block obvious page-level DOM,
+ * storage, and navigation access so unsafe bundles cannot be deployed by older
+ * templates or custom source.
+ */
+export const SEC112_DOM_ESCAPE: BundleRule = {
+ id: "SEC112",
+ severity: "block",
+ title: "DOM escape operation in UI extension bundle",
+ description:
+ "UI extension bundle accesses page-level DOM, storage, or navigation APIs outside the host-provided container",
+ sourceRuleId: "SEC012",
+ patterns: [
+ /\bdocument\.body\b/g,
+ /\bdocument\.documentElement\b/g,
+ /\bdocument\.head\b/g,
+ /\bdocument\.forms\b/g,
+ /\bdocument\.images\b/g,
+ /\bdocument\.links\b/g,
+ /\bdocument\.scripts\b/g,
+ /\bdocument\.cookie\b/g,
+ /\bdocument\.activeElement\b/g,
+ /\bdocument\.children\b/g,
+ /\bdocument\.firstElementChild\b/g,
+ /\bdocument\s*\[\s*["'](?:body|documentElement|head|forms|images|links|scripts|cookie|activeElement|children|firstElementChild|write|querySelector|querySelectorAll|getElementById|getElementsByClassName|getElementsByName|getElementsByTagName|getElementsByTagNameNS|createElement|createRange|evaluate)["']\s*\]/g,
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:window\.|globalThis\.|top\.|parent\.)?document\b[\s\S]{0,500}?\b\1\s*(?:\.|\[\s*["'])(?:body|documentElement|head|forms|images|links|scripts|cookie|activeElement|children|firstElementChild|write|querySelector|querySelectorAll|getElementById|getElementsByClassName|getElementsByName|getElementsByTagName|getElementsByTagNameNS|createElement|createRange|evaluate)/g,
+ /\b(?:const|let|var)\s*\{[^}]*\b(?:body|documentElement|head|forms|images|links|scripts|cookie|activeElement|children|firstElementChild|write|querySelector|querySelectorAll|getElementById|getElementsByClassName|getElementsByName|getElementsByTagName|getElementsByTagNameNS|createElement|createRange|evaluate)\b[^}]*\}\s*=\s*(?:window\.|globalThis\.|top\.|parent\.)?document\b/g,
+ /\bdocument\.write\s*\(/g,
+ /\bdocument\.querySelector\s*\(/g,
+ /\bdocument\.querySelectorAll\s*\(/g,
+ /\bdocument\.getElementById\s*\(/g,
+ /\bdocument\.getElementsByClassName\s*\(/g,
+ /\bdocument\.getElementsByName\s*\(/g,
+ /\bdocument\.getElementsByTagName\s*\(/g,
+ /\bdocument\.getElementsByTagNameNS\s*\(/g,
+ /\bwindow\.document\b/g,
+ /\bwindow\.location\b/g,
+ /\bwindow\.open\s*\(/g,
+ /\bwindow\s*\[\s*["'](?:document|location|open)["']\s*\]/g,
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:window|globalThis)\b[\s\S]{0,500}?\b\1\s*(?:\.|\[\s*["'])(?:document|location|open)/g,
+ /\b(?:const|let|var)\s*\{[^}]*\b(?:document|location|open)\b[^}]*\}\s*=\s*(?:window|globalThis)\b/g,
+ /\bwindow\.location\.assign\s*\(/g,
+ /\bwindow\.location\.replace\s*\(/g,
+ /\bglobalThis\.document\b/g,
+ /\bglobalThis\.location\b/g,
+ /\bglobalThis\s*\[\s*["'](?:document|location)["']\s*\]/g,
+ /\blocation\.href\b/g,
+ /\blocation\.assign\s*\(/g,
+ /\blocation\.replace\s*\(/g,
+ /\blocation\s*\[\s*["'](?:href|assign|replace)["']\s*\]/g,
+ /\bhistory\.pushState\s*\(/g,
+ /\bhistory\.replaceState\s*\(/g,
+ /\bhistory\s*\[\s*["'](?:pushState|replaceState)["']\s*\]/g,
+ /\btop\.document\b/g,
+ /\btop\.location\b/g,
+ /\btop\s*\[\s*["'](?:document|location)["']\s*\]/g,
+ /\bparent\.document\b/g,
+ /\bparent\.location\b/g,
+ /\bparent\s*\[\s*["'](?:document|location)["']\s*\]/g,
+ /\bElement\.prototype\b/g,
+ /\bNode\.prototype\b/g,
+ /\blocalStorage\b/g,
+ /\bsessionStorage\b/g,
+ /\bcontainer\.ownerDocument\b/g,
+ /\bcontainer\.parentElement\b/g,
+ /\bcontainer\.parentNode\b/g,
+ /\bcontainer\.closest\s*\(/g,
+ ],
+};
diff --git a/src/core/security/rules/bundle/index.ts b/src/core/security/rules/bundle/index.ts
index 441f99a..22092b4 100644
--- a/src/core/security/rules/bundle/index.ts
+++ b/src/core/security/rules/bundle/index.ts
@@ -1,5 +1,5 @@
/**
- * Bundle security rules (SEC101-SEC110).
+ * Bundle security rules (SEC101-SEC112).
* Exported as array for scanner consumption.
*/
export { SEC101_EVAL } from "./SEC101-eval.ts";
@@ -12,6 +12,7 @@ export { SEC107_INSPECTOR } from "./SEC107-inspector.ts";
export { SEC108_EXTERNAL_URL } from "./SEC108-external-url.ts";
export { SEC109_ENCODED_BLOB } from "./SEC109-encoded-blob.ts";
export { SEC110_SENSITIVE_OPS } from "./SEC110-sensitive-ops.ts";
+export { SEC112_DOM_ESCAPE } from "./SEC112-dom-escape.ts";
import { SEC101_EVAL } from "./SEC101-eval.ts";
import { SEC102_CHILD_PROCESS } from "./SEC102-child-process.ts";
@@ -23,6 +24,7 @@ import { SEC107_INSPECTOR } from "./SEC107-inspector.ts";
import { SEC108_EXTERNAL_URL } from "./SEC108-external-url.ts";
import { SEC109_ENCODED_BLOB } from "./SEC109-encoded-blob.ts";
import { SEC110_SENSITIVE_OPS } from "./SEC110-sensitive-ops.ts";
+import { SEC112_DOM_ESCAPE } from "./SEC112-dom-escape.ts";
import type { BundleRule } from "../../types.ts";
@@ -37,4 +39,5 @@ export const BUNDLE_RULES: BundleRule[] = [
SEC108_EXTERNAL_URL,
SEC109_ENCODED_BLOB,
SEC110_SENSITIVE_OPS,
+ SEC112_DOM_ESCAPE,
];
diff --git a/src/core/security/rules/index.ts b/src/core/security/rules/index.ts
index f3bd721..bb2b2ea 100644
--- a/src/core/security/rules/index.ts
+++ b/src/core/security/rules/index.ts
@@ -9,6 +9,7 @@ import { SEC007 } from "./SEC007-inspector.ts";
import { SEC008 } from "./SEC008-external-urls.ts";
import { SEC009 } from "./SEC009-large-blobs.ts";
import { SEC010 } from "./SEC010-sensitive-paths.ts";
+import { SEC012 } from "./SEC012-dom-escape.ts";
/**
* All security rules for extension scanning
@@ -27,6 +28,7 @@ export const RULES: Rule[] = [
SEC008, // external URLs (warn)
SEC009, // large encoded blobs (warn)
SEC010, // sensitive paths (warn)
+ SEC012, // DOM escape operations (block)
];
/**
diff --git a/src/core/security/types.ts b/src/core/security/types.ts
index b4d03ba..79f55c3 100644
--- a/src/core/security/types.ts
+++ b/src/core/security/types.ts
@@ -15,6 +15,7 @@ export type RuleId =
| "SEC009"
| "SEC010"
| "SEC011" // package.json scripts
+ | "SEC012"
| "SEC101" // bundled rules
| "SEC102"
| "SEC103"
@@ -24,7 +25,8 @@ export type RuleId =
| "SEC107"
| "SEC108"
| "SEC109"
- | "SEC110";
+ | "SEC110"
+ | "SEC112";
/**
* Severity level for security findings
diff --git a/src/services/extension/bundler.ts b/src/services/extension/bundler.ts
index 69fb6b3..c3a59ae 100644
--- a/src/services/extension/bundler.ts
+++ b/src/services/extension/bundler.ts
@@ -86,6 +86,43 @@ export function createTempDirectory(
return join(tmpdir(), "gd-cli", repoName, `deploy-${timestamp}`);
}
+export function shouldUseUiExtensionRuntimeWrapper(
+ extensionType?: ExtensionType,
+): boolean {
+ return extensionType === "checkout" || extensionType === "embed";
+}
+
+export function createUiExtensionRuntimeWrapper(entryPath: string): string {
+ return `import * as userModule from ${JSON.stringify(entryPath)};
+
+function resolveContract() {
+ if (typeof userModule.mount === "function") {
+ return userModule;
+ }
+
+ const defaultExport = userModule.default;
+ const candidate = typeof defaultExport === "function"
+ ? defaultExport()
+ : defaultExport;
+
+ if (!candidate || typeof candidate.mount !== "function") {
+ throw new Error("UI extension must export mount or a default contract/factory.");
+ }
+
+ return candidate;
+}
+
+const contract = resolveContract();
+const registry = globalThis.GoDaddyUiExtensions;
+
+if (!registry || typeof registry.register !== "function") {
+ throw new Error("UI extension runtime registry is not available.");
+}
+
+registry.register(contract);
+`;
+}
+
/**
* Cleans up temporary directory and all contents.
*/
@@ -203,9 +240,18 @@ export function bundleExtensionEffect(
const extensionDir = options.extensionDir ?? join(entryPath, "..");
const tsconfigPath = resolveTsConfig(extensionDir, options.repoRoot);
+ let buildEntryPath = entryPath;
+ if (shouldUseUiExtensionRuntimeWrapper(options.extensionType)) {
+ buildEntryPath = join(extensionTempDir, "ui-extension-runtime-entry.ts");
+ yield* fs.writeFileString(
+ buildEntryPath,
+ createUiExtensionRuntimeWrapper(entryPath),
+ );
+ }
+
// Build esbuild config
const config = buildEsbuildOptions({
- entryPath,
+ entryPath: buildEntryPath,
tsconfigPath,
extensionType: options.extensionType,
extensionDir,
@@ -215,7 +261,8 @@ export function bundleExtensionEffect(
const buildResult = yield* Effect.tryPromise({
try: () => esbuild.build(config),
catch: (error) => {
- const errorMessage = error instanceof Error ? error.message : String(error);
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
logger.error({
type: "esbuild_error",
extension: pkg.name,
@@ -242,7 +289,8 @@ export function bundleExtensionEffect(
return yield* Effect.fail(
new ConfigurationError({
message: "No output files generated by esbuild",
- userMessage: "Failed to bundle extension: No output files generated by esbuild",
+ userMessage:
+ "Failed to bundle extension: No output files generated by esbuild",
}),
);
}
@@ -251,7 +299,7 @@ export function bundleExtensionEffect(
const mapFile = outputFiles.find((f) => f.path.endsWith(".mjs.map"));
if (!mjsFile) {
- const fileList = outputFiles.map(f => f.path).join(", ");
+ const fileList = outputFiles.map((f) => f.path).join(", ");
logger.error({
type: "bundle_error",
extension: pkg.name,
diff --git a/tests/unit/core/security/bundle-scanner.test.ts b/tests/unit/core/security/bundle-scanner.test.ts
index c91fdc4..af4c8fd 100644
--- a/tests/unit/core/security/bundle-scanner.test.ts
+++ b/tests/unit/core/security/bundle-scanner.test.ts
@@ -160,4 +160,65 @@ describe("scanBundleContent", () => {
const findings = scanBundleContent(code, BUNDLE_RULES, "test.mjs");
expect(findings.length).toBeGreaterThan(1);
});
+
+ it("blocks page-level DOM access in UI extension bundles", () => {
+ const code = `
+ document.body.innerHTML = "unsafe";
+ document.cookie = "unsafe=true";
+ document.querySelector("#checkout-root");
+ document.getElementsByClassName("checkout");
+ window.location.href = "https://example.com";
+ location.replace("https://example.com");
+ history.pushState({}, "", "/unsafe");
+ window.open("https://example.com");
+ localStorage.setItem("token", "unsafe");
+ sessionStorage.setItem("token", "unsafe");
+ top.document.body.innerHTML = "unsafe";
+ parent.location.href = "https://example.com";
+ container.ownerDocument.body.innerHTML = "unsafe";
+ container.closest("#checkout-root")?.remove();
+ Element.prototype.remove = function () {};
+ `;
+ const findings = scanBundleContent(code, BUNDLE_RULES, "test.mjs");
+ const domFindings = findings.filter(
+ (finding) => finding.ruleId === "SEC112",
+ );
+
+ expect(domFindings.length).toBeGreaterThanOrEqual(15);
+ expect(domFindings.every((finding) => finding.severity === "block")).toBe(
+ true,
+ );
+ });
+
+ it("blocks aliased and bracketed DOM access in UI extension bundles", () => {
+ const code = `
+ const doc = document;
+ doc.body.innerHTML = "unsafe";
+ document["body"].innerHTML = "unsafe";
+ const win = window;
+ win.location.href = "https://example.com";
+ window["location"].href = "https://example.com";
+ `;
+ const findings = scanBundleContent(code, BUNDLE_RULES, "test.mjs");
+ const domFindings = findings.filter(
+ (finding) => finding.ruleId === "SEC112",
+ );
+
+ expect(domFindings.length).toBeGreaterThanOrEqual(4);
+ });
+
+ it("blocks destructured DOM access in UI extension bundles", () => {
+ const code = `
+ const { body } = document;
+ body.innerHTML = "unsafe";
+ const { location } = window;
+ location.href = "https://example.com";
+ `;
+ const findings = scanBundleContent(code, BUNDLE_RULES, "test.mjs");
+ const domFindings = findings.filter(
+ (finding) => finding.ruleId === "SEC112",
+ );
+
+ expect(domFindings.length).toBeGreaterThanOrEqual(2);
+ });
});
diff --git a/tests/unit/core/security/rules/SEC012-dom-escape.test.ts b/tests/unit/core/security/rules/SEC012-dom-escape.test.ts
new file mode 100644
index 0000000..e0d2020
--- /dev/null
+++ b/tests/unit/core/security/rules/SEC012-dom-escape.test.ts
@@ -0,0 +1,159 @@
+import { buildAliasMaps } from "@/core/security/alias-builder.ts";
+import { scanFile } from "@/core/security/engine.ts";
+import { SEC012 } from "@/core/security/rules/SEC012-dom-escape.ts";
+import type { SecurityConfig } from "@/core/security/types.ts";
+import ts from "typescript";
+import { describe, expect, it } from "vitest";
+
+function createSourceFile(code: string): ts.SourceFile {
+ return ts.createSourceFile(
+ "test.ts",
+ code,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS,
+ );
+}
+
+function scan(code: string) {
+ const sourceFile = createSourceFile(code);
+ const aliasMaps = buildAliasMaps(sourceFile);
+ const config: SecurityConfig = {
+ mode: "strict",
+ trustedDomains: ["*.godaddy.com"],
+ exclude: [],
+ };
+
+ return scanFile("test.ts", code, [SEC012], config, aliasMaps);
+}
+
+describe("SEC012: DOM escape operation in UI extension source", () => {
+ it("blocks page-level DOM access", () => {
+ const findings = scan(`
+ document.body.innerHTML = "unsafe";
+ document.documentElement.className = "unsafe";
+ document.head.appendChild(script);
+ document.cookie = "unsafe=true";
+ document.write("unsafe");
+ document.querySelector("#checkout-root");
+ document.querySelectorAll("button");
+ document.getElementById("checkout-root");
+ document.getElementsByClassName("checkout");
+ document.getElementsByName("payment");
+ document.getElementsByTagName("form");
+ document.createElement("script");
+ document.createRange();
+ document.evaluate("//form", document);
+ window.document.querySelector("#checkout-root");
+ globalThis.document.getElementById("checkout-root");
+ `);
+
+ expect(findings.length).toBeGreaterThanOrEqual(16);
+ expect(findings.every((finding) => finding.ruleId === "SEC012")).toBe(true);
+ expect(findings.every((finding) => finding.severity === "block")).toBe(
+ true,
+ );
+ });
+
+ it("blocks navigation, storage, and prototype mutation APIs", () => {
+ const findings = scan(`
+ window.location.href = "https://example.com";
+ window.location.assign("https://example.com");
+ location.href = "https://example.com";
+ location.replace("https://example.com");
+ globalThis.location.assign("https://example.com");
+ history.pushState({}, "", "/unsafe");
+ history.replaceState({}, "", "/unsafe");
+ window.open("https://example.com");
+ open("https://example.com");
+ localStorage.setItem("token", "unsafe");
+ sessionStorage.setItem("token", "unsafe");
+ top.document.body.innerHTML = "unsafe";
+ parent.location.href = "https://example.com";
+ Element.prototype.remove = function () {};
+ Node.prototype.appendChild = function () {};
+ `);
+
+ expect(findings.length).toBeGreaterThanOrEqual(15);
+ });
+
+ it("blocks container escape paths", () => {
+ const findings = scan(`
+ export function mount({ container }) {
+ container.ownerDocument.body.innerHTML = "unsafe";
+ container.parentElement?.remove();
+ container.parentNode?.removeChild(container);
+ container.closest("#checkout-root")?.remove();
+ }
+ `);
+
+ expect(findings.length).toBeGreaterThanOrEqual(4);
+ });
+
+ it("blocks aliased and bracketed DOM escape paths", () => {
+ const findings = scan(`
+ const doc = document;
+ doc.body.innerHTML = "unsafe";
+ document["body"].innerHTML = "unsafe";
+ const win = window;
+ win.location.href = "https://example.com";
+ window["location"].href = "https://example.com";
+ const globalDoc = window["document"];
+ globalDoc["querySelector"]("#checkout-root");
+ `);
+
+ expect(findings.length).toBeGreaterThanOrEqual(6);
+ expect(findings.every((finding) => finding.ruleId === "SEC012")).toBe(true);
+ });
+
+ it("blocks destructured page DOM escape paths", () => {
+ const findings = scan(`
+ const { body } = document;
+ body.innerHTML = "unsafe";
+ const { location } = window;
+ location.href = "https://example.com";
+ `);
+
+ expect(findings.length).toBeGreaterThanOrEqual(2);
+ expect(findings.every((finding) => finding.ruleId === "SEC012")).toBe(true);
+ });
+
+ it("allows local storage identifiers that shadow browser globals", () => {
+ const findings = scan(`
+ function render(
+ localStorage: Map,
+ sessionStorage: Map,
+ ) {
+ localStorage.set("token", "safe");
+ sessionStorage.set("token", "safe");
+ }
+
+ const localStorage = new Map();
+ localStorage.set("token", "safe");
+ `);
+
+ expect(findings).toEqual([]);
+ });
+
+ it("allows local aliases that shadow a previous DOM alias", () => {
+ const findings = scan(`
+ const doc = document;
+
+ function render(doc: { body: string }) {
+ return doc.body;
+ }
+ `);
+
+ expect(findings).toEqual([]);
+ });
+
+ it("allows rendering through the provided container", () => {
+ const findings = scan(`
+ export function mount({ container }) {
+ container.innerHTML = "Extension rendered successfully";
+ }
+ `);
+
+ expect(findings).toEqual([]);
+ });
+});
diff --git a/tests/unit/extension/bundler.test.ts b/tests/unit/extension/bundler.test.ts
index b3c10bc..68405cc 100644
--- a/tests/unit/extension/bundler.test.ts
+++ b/tests/unit/extension/bundler.test.ts
@@ -8,6 +8,7 @@ import { existsSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { pathToFileURL } from "node:url";
import {
type BundleOptions,
type BundleResult,
@@ -15,7 +16,9 @@ import {
bundleExtensionEffect,
cleanupTempDirectoryEffect,
createTempDirectory,
+ createUiExtensionRuntimeWrapper,
resolveTsConfig,
+ shouldUseUiExtensionRuntimeWrapper,
} from "@/services/extension/bundler";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runEffect } from "../../../tests/setup/effect-test-utils";
@@ -30,13 +33,119 @@ describe("bundler service", () => {
});
afterEach(async () => {
+ Reflect.deleteProperty(globalThis, "GoDaddyUiExtensions");
+
// Clean up test directory
if (existsSync(tempTestDir)) {
await rm(tempTestDir, { recursive: true, force: true });
}
});
+ async function bundleCheckoutFixture(name: string, source: string) {
+ const fixtureDir = join(tempTestDir, name);
+ const srcDir = join(fixtureDir, "src");
+ await mkdir(srcDir, { recursive: true });
+ const entryPath = join(srcDir, "index.ts");
+ await writeFile(entryPath, source);
+
+ return runEffect(
+ bundleExtensionEffect({ name, version: "1.0.0" }, entryPath, {
+ repoRoot: fixtureDir,
+ timestamp: `20250128143022-${name}`,
+ extensionDir: fixtureDir,
+ extensionType: "checkout",
+ }),
+ );
+ }
+
describe("bundleExtension", () => {
+ it("should create UI extension runtime wrapper", () => {
+ const wrapper = createUiExtensionRuntimeWrapper(
+ "/path/to/extension/src/index.ts",
+ );
+
+ expect(wrapper).toContain("import * as userModule from");
+ expect(wrapper).toContain("registry.register(contract)");
+ expect(wrapper).not.toContain("?.register");
+ expect(wrapper).toContain('typeof candidate.mount !== "function"');
+ });
+
+ it("should use runtime wrapper only for checkout and embed extensions", () => {
+ expect(shouldUseUiExtensionRuntimeWrapper("checkout")).toBe(true);
+ expect(shouldUseUiExtensionRuntimeWrapper("embed")).toBe(true);
+ expect(shouldUseUiExtensionRuntimeWrapper("blocks")).toBe(false);
+ expect(shouldUseUiExtensionRuntimeWrapper()).toBe(false);
+ });
+
+ it("should bundle checkout extension with runtime registration wrapper", async () => {
+ const fixtureDir = join(tempTestDir, "checkout-extension");
+ const srcDir = join(fixtureDir, "src");
+ await mkdir(srcDir, { recursive: true });
+ const entryPath = join(srcDir, "index.ts");
+ await writeFile(
+ entryPath,
+ `export function mount({ container }) { container.innerHTML = "Extension rendered successfully"; }`,
+ );
+
+ const result = await runEffect(
+ bundleExtensionEffect(
+ { name: "checkout-extension", version: "1.0.0" },
+ entryPath,
+ {
+ repoRoot: fixtureDir,
+ timestamp: "20250128143022",
+ extensionDir: fixtureDir,
+ extensionType: "checkout",
+ },
+ ),
+ );
+
+ const bundleContent = await readFile(result.artifactPath, "utf-8");
+ expect(bundleContent).toContain("GoDaddyUiExtensions");
+ expect(bundleContent).toContain("register");
+ expect(bundleContent).toContain("Extension rendered successfully");
+ });
+
+ it("should prefer named mount over a default function export", async () => {
+ const result = await bundleCheckoutFixture(
+ "checkout-named-mount",
+ `
+ export function mount({ container }) {
+ container.innerHTML = "Extension rendered successfully";
+ }
+
+ export default function Component() {
+ return null;
+ }
+ `,
+ );
+ let registeredContract: unknown;
+ Object.assign(globalThis, {
+ GoDaddyUiExtensions: {
+ register(contract: unknown) {
+ registeredContract = contract;
+ },
+ },
+ });
+
+ await import(`${pathToFileURL(result.artifactPath).href}?named-mount`);
+
+ expect(registeredContract).toMatchObject({
+ mount: expect.any(Function),
+ });
+ });
+
+ it("should fail when the UI extension runtime registry is unavailable", async () => {
+ const result = await bundleCheckoutFixture(
+ "checkout-missing-registry",
+ `export function mount({ container }) { container.innerHTML = "Extension rendered successfully"; }`,
+ );
+
+ await expect(
+ import(`${pathToFileURL(result.artifactPath).href}?missing-registry`),
+ ).rejects.toThrow("UI extension runtime registry is not available");
+ });
+
it("should bundle simple TypeScript extension successfully", async () => {
const fixtureDir = join(
process.cwd(),