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(),