diff --git a/plugins/power-pages/.mcp.json b/plugins/power-pages/.mcp.json index 79db1afab..9a249b93c 100644 --- a/plugins/power-pages/.mcp.json +++ b/plugins/power-pages/.mcp.json @@ -4,7 +4,7 @@ "command": "node", "args": [ "-e", - "const fs=require('node:fs'); const path=require('node:path'); const root=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.cwd(); const entry=path.resolve(root,'scripts','launch-playwright-mcp.js'); if(!fs.existsSync(entry)) throw new Error('Could not resolve Power Pages plugin root; set PLUGIN_ROOT or launch from the plugin root'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') throw new Error('Power Pages Playwright MCP launcher did not export launch()'); mod.launch();" + "const fs=require('node:fs'); const path=require('node:path'); const fail=(message)=>{throw new Error('[Power Pages Playwright MCP] '+message);}; const declaredRoot=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT; if(!declaredRoot) fail('PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT must be set; refusing to resolve the launcher from the current working directory.'); if(!path.isAbsolute(declaredRoot)) fail('Declared plugin root must be an absolute path: '+declaredRoot+'.'); let root; try{root=fs.realpathSync(declaredRoot);}catch(error){fail('Declared plugin root is invalid: '+declaredRoot+' ('+(error.code||error.message)+').');} let rootStat; try{rootStat=fs.statSync(root);}catch(error){fail('Could not inspect declared plugin root: '+root+' ('+(error.code||error.message)+').');} if(!rootStat.isDirectory()) fail('Declared plugin root is not a directory: '+declaredRoot+'.'); const candidate=path.resolve(root,'scripts','launch-playwright-mcp.js'); let entry; try{entry=fs.realpathSync(candidate);}catch(error){fail('Launcher was not found under the declared plugin root: '+candidate+'.');} const relative=path.relative(root,entry); if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative)) fail('Resolved launcher escapes the declared plugin root: '+entry+'.'); let entryStat; try{entryStat=fs.statSync(entry);}catch(error){fail('Could not inspect resolved launcher: '+entry+' ('+(error.code||error.message)+').');} if(!entryStat.isFile()) fail('Resolved launcher is not a file: '+entry+'.'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') fail('Launcher did not export launch(): '+entry+'.'); mod.launch();" ] }, "microsoft-learn": { diff --git a/plugins/power-pages/README.md b/plugins/power-pages/README.md index d559a81a1..69dfbd5f2 100644 --- a/plugins/power-pages/README.md +++ b/plugins/power-pages/README.md @@ -436,6 +436,8 @@ The plugin ships with two MCP servers configured in `.mcp.json` — they start a | **playwright** | Headless browser automation for live previews and runtime tests | | **microsoft-learn** | Grounded search/fetch over official Microsoft Learn docs | +The plugin host must provide an absolute `PLUGIN_ROOT` (GitHub Copilot) or `CLAUDE_PLUGIN_ROOT` (Claude Code). The Playwright bootstrap resolves its launcher only from that declared plugin root and never from the workspace working directory. + ## Typical Workflow A common end-to-end workflow looks like this: @@ -465,33 +467,20 @@ A common end-to-end workflow looks like this: Steps can be run independently — you don't need to follow this exact order. Each skill checks its own prerequisites and will tell you if something is missing. If something goes wrong, `/diagnose-deployment` pattern-matches deployment errors and `/report-issue` opens a pre-filled GitHub issue. -## Running Without Interruption - -The plugin invokes multiple tools during a session. To reduce approval prompts: - -**Option 1 — Permission mode (recommended)** - -```jsonc -// .claude/settings.json -{ - "defaultMode": "acceptEdits", - "permissions": { - "allow": [ - "Bash(npm run *)", - "Bash(git *)", - "Bash(pac *)", - "Bash(az *)", - "Bash(node *)" - ] - } -} -``` +## Runtime approvals -**Option 2 — Auto-accept all** +Keep your AI host's runtime approval prompts enabled while using this plugin. +Plugin scripts run on your workstation with the filesystem access and cloud sign-in state available to your user account. +A script that invokes `pac` or `az` may therefore act on Power Platform environments, Dataverse data, and Azure tenants that you can access. -```bash -claude --dangerously-skip-permissions -``` +Before approving a command, check the executable, script path, arguments, and target environment. +Pay particular attention to commands that read or change project files, environment configuration, tenant resources, or business data. +Do not grant blanket approval to command families such as `node`, `npm`, `git`, `pac`, or `az`. + +If your host supports command-specific allow rules, use them only for an exact plugin script path that you have inspected and expect to run. +Keep approval prompts for commands whose arguments or environment variables select a project, environment, tenant, or data source. +Permission features and rule syntax vary by host and version, so follow the documentation for your host. +Suppressing an approval prompt does not sandbox a script, restrict the programs it can start, or guarantee that the command is safe. ## ALM prompts you may see diff --git a/plugins/power-pages/scripts/launch-playwright-mcp.js b/plugins/power-pages/scripts/launch-playwright-mcp.js index 2762542d9..8ab84a967 100644 --- a/plugins/power-pages/scripts/launch-playwright-mcp.js +++ b/plugins/power-pages/scripts/launch-playwright-mcp.js @@ -5,45 +5,85 @@ // then falls back to Playwright's bundled Chromium. // Self-contained — no external dependencies required. -const { spawn } = require('child_process'); +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); const path = require('path'); const { detectBrowser } = require('./lib/detect-browser'); -function quoteShellArg(value, platform = process.platform) { - const argument = String(value); - - if (platform === 'win32') { - if (argument.includes('"')) { - throw new Error('Cannot quote an argument containing double quotes for cmd.exe.'); - } - - return `"${argument}"`; - } - - return `'${argument.replace(/'/g, "'\\''")}'`; -} +const PLAYWRIGHT_MCP_VERSION = '0.0.78'; +const PLAYWRIGHT_MCP_PACKAGE = `@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`; function buildMcpArgs(browser, { configPath = path.join(__dirname, 'playwright-mcp-fullscreen.config.json'), - platform = process.platform, } = {}) { + // Marketplace installs copy only this plugin directory and do not run npm install, + // so a lockfile would not materialize a local executable. Keep the runtime package + // immutable, and disable lifecycle scripts while npx prepares the reviewed version. return [ - '-y', - '@playwright/mcp@latest', + '--yes', + '--ignore-scripts', + `--package=${PLAYWRIGHT_MCP_PACKAGE}`, + 'playwright-mcp', '--browser', browser, '--config', - quoteShellArg(configPath, platform), + configPath, + ]; +} + +function resolveNpxCli({ + execPath = process.execPath, + platform = process.platform, + existsSync = fs.existsSync, +} = {}) { + // Windows exposes npx as a .cmd shim that cannot run with shell:false. Invoking + // npm's JavaScript entrypoint through Node preserves raw argv on every platform. + const pathApi = platform === 'win32' ? path.win32 : path.posix; + const nodeDir = pathApi.dirname(execPath); + const candidates = [ + pathApi.resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npx-cli.js'), + pathApi.join(nodeDir, 'node_modules', 'npm', 'bin', 'npx-cli.js'), ]; + const match = candidates.find((candidate) => existsSync(candidate)); + + if (!match) { + throw new Error( + 'Could not locate npm/bin/npx-cli.js beside the current Node installation. Install Node.js with npm before starting the Playwright MCP server.', + ); + } + + return match; } -function launch({ browser = detectBrowser(), spawnFn = spawn, onExit = (code) => process.exit(code || 0) } = {}) { - const child = spawnFn('npx', buildMcpArgs(browser), { +function launch({ + browser = detectBrowser(), + npxCliPath, + resolveNpxCliFn = resolveNpxCli, + spawnFn = spawn, + exitFn = (code) => process.exit(code), + writeError = (message) => process.stderr.write(message), +} = {}) { + let resolvedNpxCliPath = npxCliPath; + if (resolvedNpxCliPath === undefined) { + try { + resolvedNpxCliPath = resolveNpxCliFn(); + } catch (error) { + writeError(`Failed to start Playwright MCP: ${error.message}\n`); + exitFn(1); + return null; + } + } + + const child = spawnFn(process.execPath, [resolvedNpxCliPath, ...buildMcpArgs(browser)], { stdio: 'inherit', - shell: true, + shell: false, }); - child.on('exit', onExit); + child.once('error', (error) => { + writeError(`Failed to start Playwright MCP: ${error.message}\n`); + exitFn(1); + }); + child.once('exit', (code) => exitFn(code ?? 1)); return child; } @@ -51,4 +91,9 @@ if (require.main === module) { launch(); } -module.exports = { buildMcpArgs, launch, quoteShellArg }; +module.exports = { + PLAYWRIGHT_MCP_PACKAGE, + buildMcpArgs, + launch, + resolveNpxCli, +}; diff --git a/plugins/power-pages/scripts/lib/check-solution-installed.js b/plugins/power-pages/scripts/lib/check-solution-installed.js index ec83d2b21..a6e09213e 100644 --- a/plugins/power-pages/scripts/lib/check-solution-installed.js +++ b/plugins/power-pages/scripts/lib/check-solution-installed.js @@ -12,51 +12,15 @@ const helpers = require('./validation-helpers'); const UNIQUE_NAME_RE = /^[A-Za-z0-9_]+$/; /** - * Validates and normalizes a Dataverse environment URL before it is passed - * to anything that interpolates it into a shell command (notably - * helpers.getAuthToken, which calls `az account get-access-token --resource - * "${url}"` via execSync). Returns the URL's `origin` only (scheme + host + - * optional port) so path, query, fragment, and userinfo are all stripped. - * - * Throws on: - * - non-string / empty input - * - input that `new URL()` can't parse - * - non-https protocol (Dataverse refuses http and we don't want file: etc.) - * - URLs with embedded userinfo (https://user:pass@host) — credentials in - * URLs are a smell and can confuse downstream tooling - * - * The normalized origin is safe to interpolate into a shell command because - * URL.origin only contains scheme, host, and port — characters that the - * URL spec disallows from carrying shell metacharacters. + * Compatibility wrapper for existing callers. The shared validation helper + * owns the trust policy so token acquisition and authenticated requests use + * the same public and sovereign cloud endpoint allowlist. * * @param {unknown} envUrl - * @returns {string} sanitized origin, e.g. "https://contoso.crm.dynamics.com" - * @throws Error with a human-readable message on rejection + * @returns {string} validated Dataverse origin */ function sanitizeEnvUrl(envUrl) { - if (typeof envUrl !== 'string' || envUrl.trim() === '') { - throw new Error('envUrl must be a non-empty string.'); - } - - let parsed; - try { - parsed = new URL(envUrl); - } catch { - throw new Error(`envUrl is not a valid URL: "${envUrl}".`); - } - - if (parsed.protocol !== 'https:') { - throw new Error(`envUrl must use https (got "${parsed.protocol}").`); - } - - if (parsed.username || parsed.password) { - throw new Error('envUrl must not contain userinfo (username/password). Authentication uses the Azure CLI token, not credentials in the URL.'); - } - - // url.origin is the scheme + host + port — no path, no query, no fragment. - // For "https://contoso.crm.dynamics.com:443/api/data/v9.2/?x=1#anchor" - // it returns "https://contoso.crm.dynamics.com:443". - return parsed.origin; + return helpers.validateDataverseEnvironmentUrl(envUrl, 'envUrl'); } /** diff --git a/plugins/power-pages/scripts/lib/ensure-pipelines-host-detect.js b/plugins/power-pages/scripts/lib/ensure-pipelines-host-detect.js index 0de382881..814101746 100644 --- a/plugins/power-pages/scripts/lib/ensure-pipelines-host-detect.js +++ b/plugins/power-pages/scripts/lib/ensure-pipelines-host-detect.js @@ -36,8 +36,6 @@ const fs = require('fs'); const path = require('path'); -const { execSync } = require('child_process'); - const helpers = require('./validation-helpers'); const { almPath } = require('./alm-paths'); const { checkEnvHostBinding } = require('./check-env-host-binding'); @@ -86,23 +84,20 @@ function parseArgs(argv) { function originOf(url) { try { - const u = new URL(url); - return `${u.protocol}//${u.host}`; + const trustedUrl = helpers.validateAuthenticatedRequestUrl(url); + const u = new URL(trustedUrl); + return helpers.validateDataverseEnvironmentUrl(u.origin, 'Dataverse URL origin'); } catch { return null; } } function getDataverseToken(originUrl, getTokenImpl) { - if (typeof getTokenImpl === 'function') return getTokenImpl(originUrl); - try { - return execSync(`az account get-access-token --resource "${originUrl}" --query accessToken -o tsv`, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); - } catch (e) { - throw new Error(`az token acquisition failed for ${originUrl}: ${e.message || e.stderr?.toString() || 'unknown'}`); - } + const trustedOrigin = helpers.validateDataverseEnvironmentUrl(originUrl); + if (typeof getTokenImpl === 'function') return getTokenImpl(trustedOrigin); + const token = helpers.getAuthToken(trustedOrigin); + if (!token) throw new Error(`az token acquisition failed for ${trustedOrigin}`); + return token; } async function tryCacheFastPath({ projectRoot, cacheMaxAgeHours, getTokenImpl }) { @@ -122,14 +117,24 @@ async function tryCacheFastPath({ projectRoot, cacheMaxAgeHours, getTokenImpl }) // Probe with a fresh token. let token; + let trustedHostEnvUrl; + let trustedHostApiUrl = cached.finalHostInstanceApiUrl || null; try { - token = getDataverseToken(originOf(cached.finalHostEnvUrl), getTokenImpl); + trustedHostEnvUrl = originOf(cached.finalHostEnvUrl); + if (!trustedHostEnvUrl) return null; + if (trustedHostApiUrl) { + trustedHostApiUrl = helpers.validateDataverseEnvironmentUrl( + trustedHostApiUrl, + 'Cached host API URL', + ); + } + token = getDataverseToken(trustedHostEnvUrl, getTokenImpl); } catch { return null; } const verify = await verifyHostReadiness({ - hostEnvUrl: cached.finalHostEnvUrl, + hostEnvUrl: trustedHostEnvUrl, hostToken: token, skipWhoAmI: false, }); @@ -139,6 +144,8 @@ async function tryCacheFastPath({ projectRoot, cacheMaxAgeHours, getTokenImpl }) return { ...cached, schemaVersion: 2, + finalHostEnvUrl: trustedHostEnvUrl, + finalHostInstanceApiUrl: trustedHostApiUrl, cacheHit: true, cacheAgeMs: ageMs, pipelinesSolutionVersion: verify.pipelinesSolutionVersion || cached.pipelinesSolutionVersion, @@ -173,12 +180,13 @@ async function detect(opts = {}) { // BAP token is only required for source=bap. In source=pac or source=auto-with-PAC-fallback, // detection works without BAP — the shim uses PAC CLI for env list/get. if (source === 'bap' && !bapToken) throw new Error('--bapToken is required when --source bap'); + const trustedEnvUrl = helpers.validateDataverseEnvironmentUrl(envUrl); const startedAt = Date.now(); const baseOut = { schemaVersion: 2, checkedAt: new Date().toISOString(), - sourceEnvUrl: envUrl, + sourceEnvUrl: trustedEnvUrl, sourceEnvId: null, actionTaken: 'none', finalHostEnvUrl: null, @@ -205,6 +213,7 @@ async function detect(opts = {}) { if (!noCache) { const hit = await tryCacheFastPath({ projectRoot, cacheMaxAgeHours, getTokenImpl }); if (hit) { + hit.sourceEnvUrl = trustedEnvUrl; hit.detectionDurationMs = Date.now() - startedAt; hit.checkedAt = new Date().toISOString(); return hit; @@ -212,7 +221,7 @@ async function detect(opts = {}) { } // Phase 2.1 — org-setting probe - const binding = await checkEnvHostBinding({ envUrl, token }); + const binding = await checkEnvHostBinding({ envUrl: trustedEnvUrl, token }); if (binding.bound) { baseOut.sourceEnvId = binding.hostEnvId; // hostEnvId here is the env GUID stored in the org setting @@ -228,14 +237,20 @@ async function detect(opts = {}) { } baseOut.finalHostEnvId = env.envId; - baseOut.finalHostEnvUrl = env.instanceUrl; + baseOut.finalHostEnvUrl = helpers.validateDataverseEnvironmentUrl( + env.instanceUrl, + 'Resolved host environment URL', + ); baseOut.finalHostEnvName = env.displayName || null; - baseOut.finalHostInstanceApiUrl = env.instanceApiUrl; + baseOut.finalHostInstanceApiUrl = helpers.validateDataverseEnvironmentUrl( + env.instanceApiUrl, + 'Resolved host API URL', + ); baseOut.isPlatformHost = env.environmentSku === 'Platform'; // Phase 2.3 — if PE, check tenant default custom host (CannotRedirect detection) if (baseOut.isPlatformHost) { - const def = await discoverPipelinesHost({ envUrl, token, userId }); + const def = await discoverPipelinesHost({ envUrl: trustedEnvUrl, token, userId }); if (def.found && def.hostEnvUrl) { baseOut.tenantDefaultCustomHostEnvId = def.hostEnvUrl; // The org setting and tenant default are both env GUIDs. Compare them. @@ -285,9 +300,15 @@ async function detect(opts = {}) { const h = list.existingCustomHosts[0]; baseOut.resolutionStatus = 'AvailableUnboundCustomHost'; baseOut.finalHostEnvId = h.envId; - baseOut.finalHostEnvUrl = h.instanceUrl; + baseOut.finalHostEnvUrl = helpers.validateDataverseEnvironmentUrl( + h.instanceUrl, + 'Discovered host environment URL', + ); baseOut.finalHostEnvName = h.displayName || null; - baseOut.finalHostInstanceApiUrl = h.instanceApiUrl; + baseOut.finalHostInstanceApiUrl = helpers.validateDataverseEnvironmentUrl( + h.instanceApiUrl, + 'Discovered host API URL', + ); baseOut.isPlatformHost = false; baseOut.pipelinesSolutionVersion = h.pipelinesSolutionVersion || null; } else if (list.existingCustomHosts.length > 1) { @@ -297,9 +318,15 @@ async function detect(opts = {}) { const h = list.existingPlatformHost; baseOut.resolutionStatus = 'PlatformHostExistsUnbound'; baseOut.finalHostEnvId = h.envId; - baseOut.finalHostEnvUrl = h.instanceUrl; + baseOut.finalHostEnvUrl = helpers.validateDataverseEnvironmentUrl( + h.instanceUrl, + 'Discovered platform host environment URL', + ); baseOut.finalHostEnvName = h.displayName || null; - baseOut.finalHostInstanceApiUrl = h.instanceApiUrl; + baseOut.finalHostInstanceApiUrl = helpers.validateDataverseEnvironmentUrl( + h.instanceApiUrl, + 'Discovered platform host API URL', + ); baseOut.isPlatformHost = true; baseOut.pipelinesSolutionVersion = h.pipelinesSolutionVersion || null; } else { diff --git a/plugins/power-pages/scripts/lib/estimate-solution-size.js b/plugins/power-pages/scripts/lib/estimate-solution-size.js index 74e45e8ee..aeebf5350 100644 --- a/plugins/power-pages/scripts/lib/estimate-solution-size.js +++ b/plugins/power-pages/scripts/lib/estimate-solution-size.js @@ -730,9 +730,15 @@ function estimateTotalSize({ classified, tables, schemaAttrCount, webFilesAggreg * to the expected site. Safety check for solutions that accidentally contain * ppcs from multiple sites. */ -async function countSolutionMembership(envUrl, solutionId, token, sitePpcIdSet = null) { +async function countSolutionMembership( + envUrl, + solutionId, + token, + sitePpcIdSet = null, + makeRequest = helpers.makeRequest, +) { const url = `${envUrl}/api/data/v9.2/solutioncomponents?$filter=_solutionid_value eq ${solutionId}&$select=objectid,componenttype&$top=5000`; - const res = await helpers.makeRequest({ + const res = await makeRequest({ url, headers: { Authorization: `Bearer ${token}`, diff --git a/plugins/power-pages/scripts/lib/fix-blocked-attachments.js b/plugins/power-pages/scripts/lib/fix-blocked-attachments.js index d298c228d..7bed36ff9 100644 --- a/plugins/power-pages/scripts/lib/fix-blocked-attachments.js +++ b/plugins/power-pages/scripts/lib/fix-blocked-attachments.js @@ -35,7 +35,8 @@ 'use strict'; -const { execSync } = require('child_process'); +const { execFileSync } = require('child_process'); +const { validateDataverseEnvironmentUrl } = require('./validation-helpers'); function parseArgs(argv) { const args = argv.slice(2); @@ -61,12 +62,13 @@ function log(msg, quiet) { } function makePacRunner(execImpl) { - const exec = execImpl || execSync; - return function runPac(cmd) { + const exec = execImpl || execFileSync; + return function runPac(args) { try { - const out = exec(`pac ${cmd}`, { + const out = exec('pac', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + shell: false, }); return { ok: true, stdout: typeof out === 'string' ? out : (out || '') }; } catch (e) { @@ -92,11 +94,11 @@ function parseBlockedAttachmentsFromPacOutput(pacOutput) { async function fixBlockedAttachments({ envUrl, extensions, dryRun, quiet, execImpl } = {}) { const runPac = makePacRunner(execImpl); - // Build pac command args for env targeting - const envArg = envUrl ? `--environment "${envUrl}"` : ''; + const trustedEnvUrl = envUrl ? validateDataverseEnvironmentUrl(envUrl) : null; + const envArgs = trustedEnvUrl ? ['--environment', trustedEnvUrl] : []; - log(`Reading blockedattachments from ${envUrl || '(current active env)'}`, quiet); - const listResult = runPac(`env list-settings ${envArg} --filter blockedattachments`); + log(`Reading blockedattachments from ${trustedEnvUrl || '(current active env)'}`, quiet); + const listResult = runPac(['env', 'list-settings', ...envArgs, '--filter', 'blockedattachments']); if (!listResult.ok) { throw new Error(`pac env list-settings failed: ${listResult.stderr || listResult.error}`); } @@ -115,7 +117,7 @@ async function fixBlockedAttachments({ envUrl, extensions, dryRun, quiet, execIm if (wasBlocked.length === 0) { log(`Extensions [${extensions.join(', ')}] are not blocked — nothing to change`, quiet); return { - envUrl: envUrl || '(current active env)', + envUrl: trustedEnvUrl || '(current active env)', wasBlocked: [], removed: [], unchanged: extensions, @@ -133,7 +135,15 @@ async function fixBlockedAttachments({ envUrl, extensions, dryRun, quiet, execIm log(`Will remove [${wasBlocked.join(', ')}] from blockedattachments`, quiet); if (!dryRun) { - const updateResult = runPac(`env update-settings ${envArg} --name blockedattachments --value "${newValue}"`); + const updateResult = runPac([ + 'env', + 'update-settings', + ...envArgs, + '--name', + 'blockedattachments', + '--value', + newValue, + ]); if (!updateResult.ok) { throw new Error(`pac env update-settings failed: ${updateResult.stderr || updateResult.error}`); } @@ -143,7 +153,7 @@ async function fixBlockedAttachments({ envUrl, extensions, dryRun, quiet, execIm } return { - envUrl: envUrl || '(current active env)', + envUrl: trustedEnvUrl || '(current active env)', wasBlocked, removed: dryRun ? [] : wasBlocked, unchanged, diff --git a/plugins/power-pages/scripts/lib/install-pipelines-app.js b/plugins/power-pages/scripts/lib/install-pipelines-app.js index 2a4fdee9c..43c7cb2cb 100644 --- a/plugins/power-pages/scripts/lib/install-pipelines-app.js +++ b/plugins/power-pages/scripts/lib/install-pipelines-app.js @@ -59,7 +59,7 @@ 'use strict'; const crypto = require('crypto'); -const { execSync } = require('child_process'); +const { execFileSync } = require('child_process'); const helpers = require('./validation-helpers'); const DEFAULT_API_VERSION = '2022-03-01-preview'; @@ -150,8 +150,9 @@ function readRetryAfterSec(headers) { // canonical package object (name + state) or null if no Pipelines package // is exposed for this env (rare — tenant policy can hide packages). async function discoverPackage({ bapToken, envId, apiVersion, bapBase, correlationId }) { - const cleanBase = bapBase.replace(/\/+$/, ''); + const cleanBase = helpers.validateBapUrl(bapBase, { allowPath: false }).replace(/\/+$/, ''); const url = `${cleanBase}/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/${encodeURIComponent(envId)}/applicationPackages?api-version=${encodeURIComponent(apiVersion)}`; + helpers.validateBapUrl(url); const res = await helpers.makeRequest({ url, @@ -208,7 +209,7 @@ function normalizePackage(pkg) { // PAC fallback path: shells out to `pac application install`. Used when the // BAP install POST returns 401/403/5xx. -function tryPacFallback({ envId, packageUniqueName }) { +function tryPacFallback({ envId, packageUniqueName, execImpl = execFileSync }) { // Best-effort. PAC's argument names have varied across versions, so we try // the modern form first and fall through to legacy on stderr signals. const candidates = [ @@ -218,14 +219,18 @@ function tryPacFallback({ envId, packageUniqueName }) { ]; let lastErr = null; for (const argv of candidates) { - const cmd = ['pac', ...argv].map((a) => (/[\s"']/.test(a) ? `"${a}"` : a)).join(' '); try { - const out = execSync(cmd, { encoding: 'utf8', timeout: 600000, stdio: ['ignore', 'pipe', 'pipe'] }); - return { ok: true, command: cmd, stdout: out }; + const out = execImpl('pac', argv, { + encoding: 'utf8', + timeout: 600000, + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); + return { ok: true, command: ['pac', ...argv], stdout: out }; } catch (err) { lastErr = err; // Try next candidate if PAC reports an unrecognized arg / subcommand. - const stderr = (err.stderr || err.message || '').toLowerCase(); + const stderr = String(err.stderr || err.message || '').toLowerCase(); if (!/unrecognized|unknown|invalid argument/i.test(stderr)) break; } } @@ -239,7 +244,8 @@ async function verifySolutionInstalled({ instanceApiUrl, hostToken }) { if (!instanceApiUrl || !hostToken) { return { ok: false, reason: 'instanceApiUrl or hostToken not provided — caller should verify separately' }; } - const url = `${instanceApiUrl.replace(/\/+$/, '')}/api/data/v9.0/solutions?$filter=uniquename eq '${PIPELINES_SOLUTION_UNIQUE_NAME}'&$select=uniquename,version&$top=1`; + const trustedInstanceApiUrl = helpers.validateDataverseEnvironmentUrl(instanceApiUrl, 'Host Dataverse API URL'); + const url = `${trustedInstanceApiUrl}/api/data/v9.0/solutions?$filter=uniquename eq '${PIPELINES_SOLUTION_UNIQUE_NAME}'&$select=uniquename,version&$top=1`; const res = await helpers.makeRequest({ url, method: 'GET', @@ -283,7 +289,7 @@ async function installPipelinesApp(opts = {}) { const now = nowImpl || (() => Date.now()); const pacFallback = pacFallbackImpl || tryPacFallback; - const cleanBase = bapBase.replace(/\/+$/, ''); + const cleanBase = helpers.validateBapUrl(bapBase, { allowPath: false }).replace(/\/+$/, ''); const cid = correlationId || crypto.randomUUID(); const startedAt = now(); @@ -324,6 +330,7 @@ async function installPipelinesApp(opts = {}) { // BAP install POST const packageUniqueName = pkg?.uniqueName || PIPELINES_PACKAGE_UNIQUE_NAMES[0]; const postUrl = `${cleanBase}/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/${encodeURIComponent(envId)}/applicationPackages/${encodeURIComponent(packageUniqueName)}/install?api-version=${encodeURIComponent(apiVersion)}`; + helpers.validateBapUrl(postUrl); const postRes = await helpers.makeRequest({ url: postUrl, method: 'POST', @@ -400,7 +407,14 @@ async function installPipelinesApp(opts = {}) { try { respBody = JSON.parse(postRes.body); } catch { respBody = null; } } let provisioningState = extractProvisioningState(respBody) || 'Installing'; - const locationHeader = postRes.headers?.location || postRes.headers?.Location || null; + const rawLocationHeader = postRes.headers?.location || postRes.headers?.Location || null; + const locationHeader = rawLocationHeader + ? helpers.validateBapPollingUrl( + rawLocationHeader, + postUrl, + 'BAP applicationPackages install Location header', + ) + : null; let retryAfterSec = readRetryAfterSec(postRes.headers) || DEFAULT_RETRY_AFTER_SEC; if (postRes.statusCode === 200 && isTerminalSucceeded(provisioningState)) { @@ -509,6 +523,7 @@ module.exports = { isTerminalSucceeded, isTerminalFailed, readRetryAfterSec, + tryPacFallback, PIPELINES_PACKAGE_UNIQUE_NAMES, PIPELINES_PACKAGE_DISPLAY_PATTERNS, PIPELINES_SOLUTION_UNIQUE_NAME, diff --git a/plugins/power-pages/scripts/lib/list-tenant-envs.js b/plugins/power-pages/scripts/lib/list-tenant-envs.js index bd282b3c8..587441053 100644 --- a/plugins/power-pages/scripts/lib/list-tenant-envs.js +++ b/plugins/power-pages/scripts/lib/list-tenant-envs.js @@ -38,7 +38,6 @@ 'use strict'; -const { execSync } = require('child_process'); const helpers = require('./validation-helpers'); const { verifyHostReadiness } = require('./verify-host-readiness'); const { listEnvsViaPac } = require('./pac-bap-shim'); @@ -103,8 +102,9 @@ function parseArgs(argv) { async function listBapEnvs(bapToken, apiVersion, bapBase) { if (!bapToken) throw new Error('BAP token required for source=bap'); - const cleanBase = bapBase.replace(/\/+$/, ''); + const cleanBase = helpers.validateBapUrl(bapBase, { allowPath: false }).replace(/\/+$/, ''); const url = `${cleanBase}/providers/Microsoft.BusinessAppPlatform/environments?api-version=${encodeURIComponent(apiVersion)}&$expand=${encodeURIComponent('properties.linkedEnvironmentMetadata,properties.permissions')}`; + helpers.validateBapUrl(url); const res = await helpers.makeRequest({ url, @@ -175,21 +175,19 @@ async function listEnvsBySource({ source, bapToken, apiVersion, bapBase, listImp } function getDataverseToken(originUrl, getTokenImpl) { - // Pluggable for tests. Default impl shells out to `az`. - if (typeof getTokenImpl === 'function') return getTokenImpl(originUrl); - try { - const out = execSync(`az account get-access-token --resource "${originUrl}" --query accessToken -o tsv`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - return out.trim(); - } catch (e) { - throw new Error(`az token acquisition failed for ${originUrl}: ${e.message || e.stderr?.toString() || 'unknown'}`); - } + const trustedOrigin = helpers.validateDataverseEnvironmentUrl(originUrl); + if (typeof getTokenImpl === 'function') return getTokenImpl(trustedOrigin); + const token = helpers.getAuthToken(trustedOrigin); + if (!token) throw new Error(`az token acquisition failed for ${trustedOrigin}`); + return token; } // Extracts the origin (scheme + host) from a full URL. function originOf(url) { try { - const u = new URL(url); - return `${u.protocol}//${u.host}`; + const trustedUrl = helpers.validateAuthenticatedRequestUrl(url); + const u = new URL(trustedUrl); + return helpers.validateDataverseEnvironmentUrl(u.origin); } catch { return null; } diff --git a/plugins/power-pages/scripts/lib/provision-custom-host.js b/plugins/power-pages/scripts/lib/provision-custom-host.js index e2b3635a6..21a6a78b4 100644 --- a/plugins/power-pages/scripts/lib/provision-custom-host.js +++ b/plugins/power-pages/scripts/lib/provision-custom-host.js @@ -178,7 +178,7 @@ async function provisionCustomHost(opts = {}) { const sleep = sleepImpl || defaultSleep; const now = nowImpl || (() => Date.now()); - const cleanBase = bapBase.replace(/\/+$/, ''); + const cleanBase = helpers.validateBapUrl(bapBase, { allowPath: false }).replace(/\/+$/, ''); const cid = correlationId || crypto.randomUUID(); const startedAt = now(); @@ -237,7 +237,14 @@ async function provisionCustomHost(opts = {}) { // is sparse. let resolvedSku = envBody?.properties?.environmentSku || environmentSku; let provisioningState = extractProvisioningState(envBody) || 'Creating'; - const locationHeader = postRes.headers?.location || postRes.headers?.Location || null; + const rawLocationHeader = postRes.headers?.location || postRes.headers?.Location || null; + const locationHeader = rawLocationHeader + ? helpers.validateBapPollingUrl( + rawLocationHeader, + postUrl, + 'BAP env-create Location header', + ) + : null; let retryAfterSec = readRetryAfterSec(postRes.headers) || DEFAULT_RETRY_AFTER_SEC; // Already done synchronously diff --git a/plugins/power-pages/scripts/lib/provision-platform-host.js b/plugins/power-pages/scripts/lib/provision-platform-host.js index 60e371489..337979d15 100644 --- a/plugins/power-pages/scripts/lib/provision-platform-host.js +++ b/plugins/power-pages/scripts/lib/provision-platform-host.js @@ -146,7 +146,7 @@ async function provisionPlatformHost(opts = {}) { const sleep = sleepImpl || defaultSleep; const now = nowImpl || (() => Date.now()); - const cleanBase = bapBase.replace(/\/+$/, ''); + const cleanBase = helpers.validateBapUrl(bapBase, { allowPath: false }).replace(/\/+$/, ''); const cid = correlationId || crypto.randomUUID(); const startedAt = now(); @@ -204,7 +204,14 @@ async function provisionPlatformHost(opts = {}) { let displayName = envBody?.properties?.displayName || null; let resolvedSku = envBody?.properties?.environmentSku || ENVIRONMENT_SKU; let provisioningState = extractProvisioningState(envBody) || 'Creating'; - const locationHeader = postRes.headers?.location || postRes.headers?.Location || null; + const rawLocationHeader = postRes.headers?.location || postRes.headers?.Location || null; + const locationHeader = rawLocationHeader + ? helpers.validateBapPollingUrl( + rawLocationHeader, + postUrl, + 'BAP getOrCreate Location header', + ) + : null; let retryAfterSec = readRetryAfterSec(postRes.headers) || DEFAULT_RETRY_AFTER_SEC; // Idempotent existing-PE path: 200 + Succeeded means the tenant already had diff --git a/plugins/power-pages/scripts/lib/validation-helpers.js b/plugins/power-pages/scripts/lib/validation-helpers.js index 059b6b8b8..4378c8c46 100644 --- a/plugins/power-pages/scripts/lib/validation-helpers.js +++ b/plugins/power-pages/scripts/lib/validation-helpers.js @@ -5,7 +5,7 @@ const fs = require('fs'); const path = require('path'); -const { execSync } = require('child_process'); +const { execFileSync, execSync } = require('child_process'); // Exit 0 = success (allow). Exit 2 = blocking error (stderr is fed back to Claude). const approve = () => { process.exit(0); }; @@ -137,6 +137,168 @@ function findPowerPagesSiteDir(dir, subdir) { /** UUID v4 validation regex */ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// Dataverse publishes one environment-host family per supported cloud: +// Commercial/GCC: [.api].crm{region?}.dynamics.com +// GCC High: [.api].crm.microsoftdynamics.us +// DoD: [.api].crm.appsplatform.us +// China: [.api].crm.dynamics.cn +// See: https://learn.microsoft.com/power-apps/developer/data-platform/discovery-service#global-discovery-service +const DATAVERSE_HOST_PATTERNS = [ + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.(?:api\.)?crm\d*\.dynamics\.com$/, + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.(?:api\.)?crm\.microsoftdynamics\.us$/, + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.(?:api\.)?crm\.appsplatform\.us$/, + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.(?:api\.)?crm\.dynamics\.cn$/, +]; + +// These are the first-party service hosts used by this plugin's cloud maps and +// the repository's Power Platform cloud map. Keep this exact-host list narrow: +// an Authorization header must never follow a caller-controlled URL to an +// arbitrary host just because it happens to use HTTPS. +const POWER_PLATFORM_SERVICE_HOSTS = new Set([ + 'api.powerplatform.com', + 'api.gov.powerplatform.microsoft.us', + 'api.high.powerplatform.microsoft.us', + 'high.api.powerplatform.microsoft.us', + 'api.appsplatform.us', + 'dod.api.powerplatform.microsoft.us', + 'api.powerplatform.partner.microsoftonline.cn', + 'api.bap.microsoft.com', + 'gov.api.bap.microsoft.us', + 'high.api.bap.microsoft.us', + 'dod.api.bap.microsoft.us', + 'api.flow.microsoft.com', + 'gov.api.flow.microsoft.us', + 'high.gov.api.flow.microsoft.us', + 'high.api.flow.microsoft.us', + 'dod.api.flow.microsoft.us', + 'api.flow.microsoft.cn', + 'service.flow.microsoft.com', + 'gov.service.flow.microsoft.us', + 'high.gov.service.flow.microsoft.us', + 'high.service.flow.microsoft.us', + 'dod.service.flow.microsoft.us', + 'service.flow.microsoft.cn', +]); + +const BAP_HOSTS = new Set([ + 'api.bap.microsoft.com', + 'gov.api.bap.microsoft.us', + 'high.api.bap.microsoft.us', + 'dod.api.bap.microsoft.us', +]); + +function isDataverseHost(hostname) { + return DATAVERSE_HOST_PATTERNS.some((pattern) => pattern.test(hostname)); +} + +function parseTrustedMicrosoftUrl(value, { + purpose = 'URL', + allowPath = true, + allowedHost = (hostname) => isDataverseHost(hostname) || POWER_PLATFORM_SERVICE_HOSTS.has(hostname), +} = {}) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${purpose} must be a non-empty string.`); + } + + // WHATWG URL parsing removes tabs and newlines before validation and treats + // backslashes as path separators for special schemes. Reject those before + // parsing; ordinary spaces remain allowed in OData query expressions and are + // percent-encoded by URL.href below. + if (/[\u0000-\u001f\u007f\\]/.test(value)) { + throw new Error(`${purpose} contains control characters or backslashes.`); + } + + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${purpose} is not a valid URL.`); + } + + if (parsed.protocol !== 'https:') { + throw new Error(`${purpose} must use HTTPS.`); + } + if (parsed.username || parsed.password) { + throw new Error(`${purpose} must not contain credentials.`); + } + if (parsed.hash) { + throw new Error(`${purpose} must not contain a fragment.`); + } + + // URL.port is empty for an explicit default :443, so inspect the original + // authority as well. Microsoft service endpoints used here never require a + // caller-selected port. URL schemes are case-insensitive, so capture the raw + // authority without requiring callers to use lowercase `https://`. + const authorityMatch = /^https:\/\/([^/?#]*)/i.exec(value); + if (!authorityMatch) { + throw new Error(`${purpose} must use HTTPS.`); + } + const authority = authorityMatch[1]; + if (authority.includes(':')) { + throw new Error(`${purpose} must not contain a port.`); + } + if (!/^[A-Za-z0-9.-]+$/.test(authority) || parsed.hostname.includes('xn--')) { + throw new Error(`${purpose} contains unsafe host characters.`); + } + + const hostname = parsed.hostname.toLowerCase(); + if (!allowedHost(hostname)) { + throw new Error(`${purpose} host "${hostname}" is not an allowed Microsoft Dataverse or Power Platform endpoint.`); + } + if (!allowPath && (parsed.pathname !== '/' || parsed.search)) { + throw new Error(`${purpose} must be an HTTPS origin without a path or query.`); + } + + return parsed; +} + +function validateDataverseEnvironmentUrl(value, purpose = 'Dataverse environment URL') { + return parseTrustedMicrosoftUrl(value, { + purpose, + allowPath: false, + allowedHost: isDataverseHost, + }).origin; +} + +function validateTokenResourceUrl(value) { + const parsed = parseTrustedMicrosoftUrl(value, { + purpose: 'Token resource URL', + allowPath: false, + }); + return parsed.origin + (value.endsWith('/') ? '/' : ''); +} + +function validateAuthenticatedRequestUrl(value) { + return parseTrustedMicrosoftUrl(value, { + purpose: 'Authenticated request URL', + allowPath: true, + }).href; +} + +function validateBapUrl(value, { allowPath = true } = {}) { + return parseTrustedMicrosoftUrl(value, { + purpose: 'BAP URL', + allowPath, + allowedHost: (hostname) => BAP_HOSTS.has(hostname), + }).href; +} + +function validateBapPollingUrl(location, initiatingUrl, purpose = 'BAP Location header') { + const trustedInitiatingUrl = validateBapUrl(initiatingUrl); + let resolvedLocation; + try { + resolvedLocation = new URL(location, trustedInitiatingUrl).href; + } catch { + throw new Error(`${purpose} is not a valid URL.`); + } + + const trustedPollingUrl = validateBapUrl(resolvedLocation); + if (new URL(trustedPollingUrl).origin !== new URL(trustedInitiatingUrl).origin) { + throw new Error(`${purpose} points to a different host than the initiating BAP request.`); + } + return trustedPollingUrl; +} + /** * Gets an Azure CLI access token for the given resource URL. * The `--allow-no-subscriptions` flag is only valid on `az login` (other `az` @@ -147,9 +309,11 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 */ function getAuthToken(resourceUrl) { try { - return execSync( - `az account get-access-token --resource "${resourceUrl}" --query accessToken -o tsv`, - { encoding: 'utf8', timeout: 15000 } + const trustedResourceUrl = validateTokenResourceUrl(resourceUrl); + return execFileSync( + 'az', + ['account', 'get-access-token', '--resource', trustedResourceUrl, '--query', 'accessToken', '-o', 'tsv'], + { encoding: 'utf8', timeout: 15000, shell: false } ).trim(); } catch { return null; @@ -172,7 +336,12 @@ function getAuthToken(resourceUrl) { function parseEnvironmentUrl(whoOutput) { if (!whoOutput) return null; const match = whoOutput.match(/(?:Org URL|Environment URL):\s*(https:\/\/[^\s]+)/i); - return match ? match[1].replace(/\/+$/, '') : null; + if (!match) return null; + try { + return validateDataverseEnvironmentUrl(match[1]); + } catch { + return null; + } } function getEnvironmentUrl() { @@ -216,10 +385,14 @@ function getPacAuthInfo() { * @returns {Promise<{ statusCode: number, body: string, headers?: object } | { error: string }>} */ function makeRequest({ url, method = 'GET', headers = {}, body = null, includeHeaders = false, timeout = 15000 }) { + const authorizationHeader = Object.entries(headers) + .find(([name, value]) => name.toLowerCase() === 'authorization' && value); + const requestUrl = authorizationHeader ? validateAuthenticatedRequestUrl(url) : url; + return new Promise((resolve) => { const https = require('https'); const http = require('http'); - const u = new URL(url); + const u = new URL(requestUrl); const mod = u.protocol === 'https:' ? https : http; const req = mod.request( { @@ -335,6 +508,11 @@ module.exports = { findProjectRoot, findPowerPagesSiteDir, UUID_REGEX, + validateDataverseEnvironmentUrl, + validateTokenResourceUrl, + validateAuthenticatedRequestUrl, + validateBapUrl, + validateBapPollingUrl, getAuthToken, makeRequest, odataGet, diff --git a/plugins/power-pages/scripts/tests/check-solution-installed.test.js b/plugins/power-pages/scripts/tests/check-solution-installed.test.js index c466308d2..70b734d9d 100644 --- a/plugins/power-pages/scripts/tests/check-solution-installed.test.js +++ b/plugins/power-pages/scripts/tests/check-solution-installed.test.js @@ -140,13 +140,11 @@ test('throws when the response body is not valid JSON', async (t) => { ); }); -// --- sanitizeEnvUrl: defense against command injection via --envUrl --- +// --- sanitizeEnvUrl: trusted token and request destination enforcement --- // -// The output of sanitizeEnvUrl is passed to helpers.getAuthToken, which -// interpolates it into `az account get-access-token --resource "${url}"` -// via execSync (a shell command). If we didn't sanitize, an attacker who -// could pass a malicious --envUrl on the CLI could execute arbitrary -// shell commands. +// The shared validator restricts token resources and authenticated requests to +// known Microsoft Dataverse hosts. getAuthToken also passes the URL as one +// execFileSync argument, so shell metacharacters are never command syntax. test('sanitizeEnvUrl accepts a plain Dataverse URL and returns just the origin', () => { assert.equal( @@ -155,18 +153,27 @@ test('sanitizeEnvUrl accepts a plain Dataverse URL and returns just the origin', ); }); -test('sanitizeEnvUrl strips path, query, and fragment from the URL', () => { - assert.equal( - sanitizeEnvUrl('https://contoso.crm.dynamics.com/api/data/v9.2/solutions?$top=1#hash'), - 'https://contoso.crm.dynamics.com' +test('sanitizeEnvUrl rejects paths, queries, and fragments', () => { + assert.throws( + () => sanitizeEnvUrl('https://contoso.crm.dynamics.com/api/data/v9.2/solutions'), + /without a path or query/, + ); + assert.throws( + () => sanitizeEnvUrl('https://contoso.crm.dynamics.com?$top=1'), + /without a path or query/, + ); + assert.throws( + () => sanitizeEnvUrl('https://contoso.crm.dynamics.com#hash'), + /must not contain a fragment/, ); }); -test('sanitizeEnvUrl preserves an explicit port', () => { - assert.equal( - sanitizeEnvUrl('https://contoso.crm.dynamics.com:8443/some/path'), - 'https://contoso.crm.dynamics.com:8443' +test('sanitizeEnvUrl rejects explicit ports', () => { + assert.throws( + () => sanitizeEnvUrl('https://contoso.crm.dynamics.com:8443'), + /must not contain a port/, ); + assert.throws(() => sanitizeEnvUrl('https://contoso.crm.dynamics.com:443'), /must not contain a port/); }); test('sanitizeEnvUrl strips a trailing slash by normalizing to origin', () => { @@ -177,54 +184,32 @@ test('sanitizeEnvUrl strips a trailing slash by normalizing to origin', () => { }); test('sanitizeEnvUrl rejects shell-injection payloads embedded in the URL', () => { - // The whole point of using URL.origin is that these characters are stripped - // (in path/query/fragment) or rejected by URL parsing (in host). - // Verify a few representative payloads no longer make it through. - - // Path-position payload: URL parses fine, but origin throws away the path. - assert.equal( - sanitizeEnvUrl('https://contoso.crm.dynamics.com/"; rm -rf ~; echo "'), - 'https://contoso.crm.dynamics.com' - ); - - // Query-position payload: same story. - assert.equal( - sanitizeEnvUrl('https://contoso.crm.dynamics.com?x="; rm -rf ~; echo "'), - 'https://contoso.crm.dynamics.com' - ); - - // Newline in the URL — WHATWG URL parsing strips ASCII tabs and newlines - // per spec, so a newline-laced URL gets normalized to a safe origin rather - // than carrying the newline downstream. This is the behavior we want — a - // newline in a shell command argument can be used to break out of a quoted - // string. - assert.equal( - sanitizeEnvUrl('https://contoso\ndynamics.com'), - 'https://contosodynamics.com' - ); - assert.doesNotMatch(sanitizeEnvUrl('https://contoso\tdynamics.com'), /\s/); + assert.throws(() => sanitizeEnvUrl('https://contoso.crm.dynamics.com/;echo-marker')); + assert.throws(() => sanitizeEnvUrl('https://contoso.crm.dynamics.com/&echo-marker%PATH%')); + assert.throws(() => sanitizeEnvUrl('https://contoso\ndynamics.com'), /control characters/); + assert.throws(() => sanitizeEnvUrl('https://contoso\tdynamics.com'), /control characters/); }); test('sanitizeEnvUrl rejects non-https protocols', () => { - assert.throws(() => sanitizeEnvUrl('http://contoso.crm.dynamics.com'), /must use https/); - assert.throws(() => sanitizeEnvUrl('file:///etc/passwd'), /must use https/); - assert.throws(() => sanitizeEnvUrl('javascript:alert(1)'), /must use https/); + assert.throws(() => sanitizeEnvUrl('http://contoso.crm.dynamics.com'), /must use HTTPS/); + assert.throws(() => sanitizeEnvUrl('file:///etc/passwd'), /must use HTTPS/); + assert.throws(() => sanitizeEnvUrl('javascript:alert(1)'), /must use HTTPS/); }); test('sanitizeEnvUrl rejects URLs containing userinfo (credentials)', () => { assert.throws( () => sanitizeEnvUrl('https://attacker:pwn@contoso.crm.dynamics.com'), - /must not contain userinfo/ + /must not contain credentials/ ); assert.throws( () => sanitizeEnvUrl('https://attacker@contoso.crm.dynamics.com'), - /must not contain userinfo/ + /must not contain credentials/ ); }); test('sanitizeEnvUrl rejects garbage input', () => { assert.throws(() => sanitizeEnvUrl(''), /non-empty string/); - assert.throws(() => sanitizeEnvUrl(' '), /non-empty string/); + assert.throws(() => sanitizeEnvUrl(' '), /not a valid URL/); assert.throws(() => sanitizeEnvUrl(null), /non-empty string/); assert.throws(() => sanitizeEnvUrl(undefined), /non-empty string/); assert.throws(() => sanitizeEnvUrl(42), /non-empty string/); diff --git a/plugins/power-pages/scripts/tests/ensure-pipelines-host-detect.test.js b/plugins/power-pages/scripts/tests/ensure-pipelines-host-detect.test.js index 4267b0a37..11f44233e 100644 --- a/plugins/power-pages/scripts/tests/ensure-pipelines-host-detect.test.js +++ b/plugins/power-pages/scripts/tests/ensure-pipelines-host-detect.test.js @@ -56,9 +56,14 @@ test('throws when required args are missing', async () => { test('AvailableUsingCustomHost: org-setting bound to a non-Platform env', async (t) => { const tmp = makeTmpDir(); + const mixedCaseEnv = JSON.parse(JSON.stringify(SAMPLE_ENV_RESPONSE)); + mixedCaseEnv.properties.linkedEnvironmentMetadata.instanceUrl = + 'HTTPS://PascalePipelinesHost.CRM.Dynamics.Com/'; + mixedCaseEnv.properties.linkedEnvironmentMetadata.instanceApiUrl = + 'HTTPS://PascalePipelinesHost.API.CRM.Dynamics.Com/'; withMockedHttp(t, [ { match: (u) => u.includes('/GetOrgDbOrgSetting'), respond: () => ({ statusCode: 200, body: JSON.stringify({ SettingValue: '0817fd3d-a664-e99a-a758-dd9dc03ceb01' }) }) }, - { match: (u) => u.includes('/Microsoft.BusinessAppPlatform/environments/'), respond: () => ({ statusCode: 200, body: JSON.stringify(SAMPLE_ENV_RESPONSE) }) }, + { match: (u) => u.includes('/Microsoft.BusinessAppPlatform/environments/'), respond: () => ({ statusCode: 200, body: JSON.stringify(mixedCaseEnv) }) }, { match: (u) => u.includes('/WhoAmI'), respond: () => ({ statusCode: 200, body: JSON.stringify({ UserId: 'u' }) }) }, { match: (u) => u.includes('/solutions'), respond: () => ({ statusCode: 200, body: JSON.stringify({ value: [{ uniquename: 'msdyn_AppDeploymentAnchor', version: '9.1.2026034.260325188' }] }) }) }, ]); @@ -73,8 +78,9 @@ test('AvailableUsingCustomHost: org-setting bound to a non-Platform env', async }); assert.equal(result.resolutionStatus, 'AvailableUsingCustomHost'); - assert.equal(result.finalHostEnvUrl, 'https://pascalepipelineshost.crm.dynamics.com/'); + assert.equal(result.finalHostEnvUrl, 'https://pascalepipelineshost.crm.dynamics.com'); assert.equal(result.finalHostInstanceApiUrl, 'https://pascalepipelineshost.api.crm.dynamics.com'); + assert.equal(result.finalHostEnvName, 'PA Staff Pipelines Host'); assert.equal(result.isPlatformHost, false); assert.equal(result.ready, true); assert.equal(result.pipelinesSolutionVersion, '9.1.2026034.260325188'); @@ -96,7 +102,7 @@ test('AvailableUsingPlatformHost: bound to PE, no tenant default custom host', a ]); const result = await detect({ - envUrl: 'https://x.crm.dynamics.com', + envUrl: 'HTTPS://X.CRM.DYNAMICS.COM/', token: 'dv', userId: 'u', bapToken: 'bap', @@ -107,6 +113,7 @@ test('AvailableUsingPlatformHost: bound to PE, no tenant default custom host', a assert.equal(result.resolutionStatus, 'AvailableUsingPlatformHost'); assert.equal(result.isPlatformHost, true); assert.equal(result.tenantDefaultCustomHostEnvId, null); + assert.equal(result.sourceEnvUrl, 'https://x.crm.dynamics.com'); assert.equal(result.ready, true); }); @@ -122,7 +129,7 @@ test('CannotRedirect: bound to PE but tenant default custom host points elsewher ]); const result = await detect({ - envUrl: 'https://x.crm.dynamics.com', + envUrl: 'HTTPS://X.CRM.DYNAMICS.COM/', token: 'dv', userId: 'u', bapToken: 'bap', @@ -133,7 +140,7 @@ test('CannotRedirect: bound to PE but tenant default custom host points elsewher assert.equal(result.resolutionStatus, 'CannotRedirect'); assert.equal(result.tenantDefaultCustomHostEnvId, 'different-host-guid'); assert.match(result.warnings[0], /CannotRedirect/); - assert.equal(result.finalHostEnvUrl, 'https://pascalepipelineshost.crm.dynamics.com/'); + assert.equal(result.finalHostEnvUrl, 'https://pascalepipelineshost.crm.dynamics.com'); assert.equal(result.ready, false); // didn't run verify because we early-returned }); @@ -157,6 +164,32 @@ test('OrgSettingStale: org binding points at env that returns 404 from BAP', asy assert.equal(result.finalHostEnvUrl, null); }); +test('rejects Dataverse-derived host URLs outside the Microsoft cloud allowlist', async (t) => { + const tmp = makeTmpDir(); + const maliciousEnv = JSON.parse(JSON.stringify(SAMPLE_ENV_RESPONSE)); + maliciousEnv.properties.linkedEnvironmentMetadata.instanceUrl = 'https://host.crm.dynamics.com.attacker.invalid/'; + maliciousEnv.properties.linkedEnvironmentMetadata.instanceApiUrl = 'https://host.api.crm.dynamics.com.attacker.invalid'; + let tokenCalls = 0; + + withMockedHttp(t, [ + { match: (u) => u.includes('/GetOrgDbOrgSetting'), respond: () => ({ statusCode: 200, body: JSON.stringify({ SettingValue: 'bound-host-id' }) }) }, + { match: (u) => u.includes('/Microsoft.BusinessAppPlatform/environments/'), respond: () => ({ statusCode: 200, body: JSON.stringify(maliciousEnv) }) }, + ]); + + await assert.rejects( + () => detect({ + envUrl: 'https://source.crm.dynamics.com', + token: 'dv', + userId: 'u', + bapToken: 'bap', + projectRoot: tmp, + getTokenImpl: () => { tokenCalls++; return 'must-not-run'; }, + }), + /not an allowed Microsoft Dataverse or Power Platform endpoint/, + ); + assert.equal(tokenCalls, 0); +}); + test('NoHost: unbound + tenant has no custom hosts and no PE', async (t) => { const tmp = makeTmpDir(); withMockedHttp(t, [ @@ -180,7 +213,7 @@ test('NoHost: unbound + tenant has no custom hosts and no PE', async (t) => { assert.equal(result.candidates.existingPlatformHost, null); }); -test('AvailableUnboundCustomHost: unbound + exactly one Custom Host found', async (t) => { +test('AvailableUnboundCustomHost: canonicalizes mixed-case discovered host URLs', async (t) => { const tmp = makeTmpDir(); withMockedHttp(t, [ { match: (u) => u.includes('/GetOrgDbOrgSetting'), respond: () => ({ statusCode: 200, body: JSON.stringify({ SettingValue: '' }) }) }, @@ -195,8 +228,8 @@ test('AvailableUnboundCustomHost: unbound + exactly one Custom Host found', asyn displayName: 'PA Staff Pipelines Host', environmentSku: 'Production', linkedEnvironmentMetadata: { - instanceUrl: 'https://pascalepipelineshost.crm.dynamics.com/', - instanceApiUrl: 'https://pascalepipelineshost.api.crm.dynamics.com', + instanceUrl: 'HTTPS://PascalePipelinesHost.CRM.Dynamics.Com/', + instanceApiUrl: 'HTTPS://PascalePipelinesHost.API.CRM.Dynamics.Com/', domainName: 'pascalepipelineshost', }, }, @@ -215,7 +248,10 @@ test('AvailableUnboundCustomHost: unbound + exactly one Custom Host found', asyn }); assert.equal(result.resolutionStatus, 'AvailableUnboundCustomHost'); - assert.equal(result.finalHostEnvUrl, 'https://pascalepipelineshost.crm.dynamics.com/'); + assert.equal(result.finalHostEnvUrl, 'https://pascalepipelineshost.crm.dynamics.com'); + assert.equal(result.finalHostInstanceApiUrl, 'https://pascalepipelineshost.api.crm.dynamics.com'); + assert.equal(result.sourceEnvUrl, 'https://x.crm.dynamics.com'); + assert.equal(result.finalHostEnvName, 'PA Staff Pipelines Host'); assert.equal(result.ready, true); }); @@ -227,9 +263,9 @@ test('cache fast-path: returns immediately when docs/alm/last-host-check.json is sourceEnvUrl: 'https://x.crm.dynamics.com', sourceEnvId: 'src', resolutionStatus: 'AvailableUsingCustomHost', - finalHostEnvUrl: 'https://pascalepipelineshost.crm.dynamics.com/', + finalHostEnvUrl: 'HTTPS://PascalePipelinesHost.CRM.Dynamics.Com/', finalHostEnvId: '0817fd3d', - finalHostInstanceApiUrl: 'https://pascalepipelineshost.api.crm.dynamics.com', + finalHostInstanceApiUrl: 'HTTPS://PascalePipelinesHost.API.CRM.Dynamics.Com/', isPlatformHost: false, actionTaken: 'none', pipelinesSolutionVersion: '9.1', @@ -248,17 +284,24 @@ test('cache fast-path: returns immediately when docs/alm/last-host-check.json is { match: (u) => u.includes('/solutions'), respond: () => ({ statusCode: 200, body: JSON.stringify({ value: [{ version: '9.1' }] }) }) }, ]); + let tokenResource = null; const result = await detect({ - envUrl: 'https://x.crm.dynamics.com', + envUrl: 'HTTPS://X.CRM.DYNAMICS.COM/', token: 'dv', userId: 'u', bapToken: 'bap', projectRoot: tmp, - getTokenImpl: () => 't', + getTokenImpl: (resource) => { + tokenResource = resource; + return 't'; + }, }); assert.equal(result.cacheHit, true); - assert.equal(result.finalHostEnvUrl, 'https://pascalepipelineshost.crm.dynamics.com/'); + assert.equal(result.finalHostEnvUrl, 'https://pascalepipelineshost.crm.dynamics.com'); + assert.equal(result.finalHostInstanceApiUrl, 'https://pascalepipelineshost.api.crm.dynamics.com'); + assert.equal(result.sourceEnvUrl, 'https://x.crm.dynamics.com'); + assert.equal(tokenResource, result.finalHostEnvUrl); assert.equal(bindingCalled, false, 'cache hit should skip the org-setting probe'); }); diff --git a/plugins/power-pages/scripts/tests/fix-blocked-attachments.test.js b/plugins/power-pages/scripts/tests/fix-blocked-attachments.test.js index af17fdc3f..bbb834ab5 100644 --- a/plugins/power-pages/scripts/tests/fix-blocked-attachments.test.js +++ b/plugins/power-pages/scripts/tests/fix-blocked-attachments.test.js @@ -9,9 +9,9 @@ blockedattachments ${SAMPLE_BLOCKED} `; function fakeExec(listOut, updateOut) { - return (cmd) => { - if (cmd.includes('list-settings')) return listOut; - if (cmd.includes('update-settings')) return updateOut || "Setting 'blockedattachments' updated successfully"; + return (_file, args) => { + if (args.includes('list-settings')) return listOut; + if (args.includes('update-settings')) return updateOut || "Setting 'blockedattachments' updated successfully"; return ''; }; } @@ -58,9 +58,9 @@ test('dry-run does not call update-settings', async () => { extensions: ['js'], dryRun: true, quiet: true, - execImpl: (cmd) => { - if (cmd.includes('list-settings')) return SAMPLE_PAC_OUTPUT; - if (cmd.includes('update-settings')) { updateCalled = true; return 'ok'; } + execImpl: (_file, args) => { + if (args.includes('list-settings')) return SAMPLE_PAC_OUTPUT; + if (args.includes('update-settings')) { updateCalled = true; return 'ok'; } return ''; }, }); @@ -70,19 +70,61 @@ test('dry-run does not call update-settings', async () => { assert.deepEqual(result.wasBlocked, ['js']); }); -test('passes --environment arg when envUrl provided', async () => { - let capturedCmd = null; +test('passes --environment as a literal argv value with shell disabled', async () => { + const calls = []; await fixBlockedAttachments({ envUrl: 'https://staging.crm.dynamics.com', extensions: ['js'], quiet: true, - execImpl: (cmd) => { - capturedCmd = cmd; - if (cmd.includes('list-settings')) return SAMPLE_PAC_OUTPUT; + execImpl: (file, args, options) => { + calls.push({ file, args, options }); + if (args.includes('list-settings')) return SAMPLE_PAC_OUTPUT; return "Setting 'blockedattachments' updated successfully"; }, }); - assert.match(capturedCmd, /--environment "https:\/\/staging\.crm\.dynamics\.com"/); + assert.equal(calls.length, 2); + for (const call of calls) { + assert.equal(call.file, 'pac'); + assert.equal(call.options.shell, false); + assert.deepEqual( + call.args.slice(call.args.indexOf('--environment'), call.args.indexOf('--environment') + 2), + ['--environment', 'https://staging.crm.dynamics.com'], + ); + } +}); + +test('passes Dataverse-derived setting metacharacters as one literal argv value', async () => { + const currentValue = 'exe;dll;&marker|%PATH%!;js'; + let updateArgs = null; + await fixBlockedAttachments({ + extensions: ['js'], + quiet: true, + execImpl: (_file, args, options) => { + assert.equal(options.shell, false); + if (args.includes('list-settings')) { + return `Setting Value\nblockedattachments ${currentValue}\n`; + } + updateArgs = args; + return 'updated'; + }, + }); + + const valueIndex = updateArgs.indexOf('--value'); + assert.equal(updateArgs[valueIndex + 1], 'exe;dll;&marker|%path%!'); +}); + +test('rejects unsafe environment URLs before invoking pac', async () => { + let called = false; + await assert.rejects( + () => fixBlockedAttachments({ + envUrl: 'https://staging.crm.dynamics.com:8443', + extensions: ['js'], + quiet: true, + execImpl: () => { called = true; return ''; }, + }), + /must not contain a port/, + ); + assert.equal(called, false); }); test('throws on pac list-settings failure', async () => { @@ -101,8 +143,8 @@ test('throws when blockedattachments line not found in output', async () => { () => fixBlockedAttachments({ extensions: ['js'], quiet: true, - execImpl: (cmd) => { - if (cmd.includes('list-settings')) return 'Connected\nSetting not found here\n'; + execImpl: (_file, args) => { + if (args.includes('list-settings')) return 'Connected\nSetting not found here\n'; return ''; }, }), diff --git a/plugins/power-pages/scripts/tests/install-pipelines-app.test.js b/plugins/power-pages/scripts/tests/install-pipelines-app.test.js index abaa0bb12..96581da50 100644 --- a/plugins/power-pages/scripts/tests/install-pipelines-app.test.js +++ b/plugins/power-pages/scripts/tests/install-pipelines-app.test.js @@ -5,6 +5,7 @@ const { installPipelinesApp, discoverPackage, isTerminalSucceeded, + tryPacFallback, PIPELINES_PACKAGE_UNIQUE_NAMES, PIPELINES_SOLUTION_UNIQUE_NAME, } = require('../lib/install-pipelines-app'); @@ -71,10 +72,143 @@ test('PIPELINES_PACKAGE_UNIQUE_NAMES contains msdyn_AppDeploymentAnchor as the p assert.equal(PIPELINES_PACKAGE_UNIQUE_NAMES[0], 'msdyn_AppDeploymentAnchor'); }); +test('PAC fallback uses argv arrays with shell disabled for POSIX and Windows metacharacters', () => { + const calls = []; + const envId = 'env-id;marker&%PATH%!'; + const result = tryPacFallback({ + envId, + packageUniqueName: PACKAGE_NAME, + execImpl: (file, args, options) => { + calls.push({ file, args, options }); + return 'Installed'; + }, + }); + + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].file, 'pac'); + assert.equal(calls[0].options.shell, false); + assert.equal(calls[0].args[calls[0].args.indexOf('--environment-id') + 1], envId); +}); + +test('PAC fallback handles Buffer stderr while trying compatible argument forms', () => { + let calls = 0; + const result = tryPacFallback({ + envId: ENV_ID, + packageUniqueName: PACKAGE_NAME, + execImpl: () => { + calls++; + if (calls === 1) { + const error = new Error('PAC rejected the first argument form'); + error.stderr = Buffer.from('Unknown argument: --environment-id'); + throw error; + } + return 'Installed'; + }, + }); + + assert.equal(result.ok, true); + assert.equal(calls, 2); +}); + +test('discoverPackage accepts a documented sovereign BAP host', async (t) => { + withMockedHttp(t, [ + { + match: (u) => u.startsWith('https://high.api.bap.microsoft.us/'), + respond: () => ({ statusCode: 200, body: JSON.stringify(PACKAGE_LIST_AVAILABLE) }), + }, + ]); + const pkg = await discoverPackage({ + bapToken: 'fake', + envId: ENV_ID, + apiVersion: '2022-03-01-preview', + bapBase: 'https://high.api.bap.microsoft.us', + correlationId: 'cid-sovereign', + }); + assert.equal(pkg.uniqueName, PACKAGE_NAME); +}); + test('PIPELINES_SOLUTION_UNIQUE_NAME matches the post-install Dataverse probe target', () => { assert.equal(PIPELINES_SOLUTION_UNIQUE_NAME, 'msdyn_AppDeploymentAnchor'); }); +test('rejects an untrusted BAP Location header before polling with the bearer token', async (t) => { + withMockedHttp(t, [ + { + match: (u, args) => u.includes('/applicationPackages?') && args.method === 'GET', + respond: () => ({ statusCode: 200, body: JSON.stringify(PACKAGE_LIST_AVAILABLE) }), + }, + { + match: (u, args) => u.includes('/install?') && args.method === 'POST', + respond: () => ({ + statusCode: 202, + headers: { location: 'https://attacker.invalid/lifecycle/op-1' }, + body: JSON.stringify({ properties: { provisioningState: 'Installing' } }), + }), + }, + ]); + + await assert.rejects( + () => installPipelinesApp({ bapToken: 'fake', envId: ENV_ID, sleepImpl: noSleep }), + /BAP URL host .* is not an allowed/, + ); +}); + +test('rejects a cross-cloud BAP Location header before polling', async (t) => { + withMockedHttp(t, [ + { + match: (u, args) => u.includes('/applicationPackages?') && args.method === 'GET', + respond: () => ({ statusCode: 200, body: JSON.stringify(PACKAGE_LIST_AVAILABLE) }), + }, + { + match: (u, args) => u.includes('/install?') && args.method === 'POST', + respond: () => ({ + statusCode: 202, + headers: { location: 'https://high.api.bap.microsoft.us/lifecycle/op-1' }, + body: JSON.stringify({ properties: { provisioningState: 'Installing' } }), + }), + }, + ]); + + await assert.rejects( + () => installPipelinesApp({ bapToken: 'fake', envId: ENV_ID, sleepImpl: noSleep }), + /Location header.*different host/, + ); +}); + +test('resolves a same-host relative BAP Location header before polling', async (t) => { + const expectedPollUrl = 'https://api.bap.microsoft.com/lifecycle/op-relative'; + withMockedHttp(t, [ + { + match: (u, args) => u.includes('/applicationPackages?') && args.method === 'GET', + respond: () => ({ statusCode: 200, body: JSON.stringify(PACKAGE_LIST_AVAILABLE) }), + }, + { + match: (u, args) => u.includes('/install?') && args.method === 'POST', + respond: () => ({ + statusCode: 202, + headers: { location: '/lifecycle/op-relative' }, + body: JSON.stringify({ properties: { provisioningState: 'Installing' } }), + }), + }, + { + match: (u, args) => u === expectedPollUrl && args.method === 'GET', + respond: () => ({ + statusCode: 200, + body: JSON.stringify({ properties: { provisioningState: 'Installed' } }), + }), + }, + ]); + + const result = await installPipelinesApp({ + bapToken: 'fake', + envId: ENV_ID, + sleepImpl: noSleep, + }); + assert.equal(result.locationHeader, expectedPollUrl); + assert.equal(result.status, 'Succeeded'); +}); + test('isTerminalSucceeded recognises both "Succeeded" and "Installed" terminal states', () => { assert.equal(isTerminalSucceeded('Succeeded'), true); assert.equal(isTerminalSucceeded('Installed'), true); diff --git a/plugins/power-pages/scripts/tests/integration/discover-integration.test.js b/plugins/power-pages/scripts/tests/integration/discover-integration.test.js index e7a317663..b7dd12f4e 100644 --- a/plugins/power-pages/scripts/tests/integration/discover-integration.test.js +++ b/plugins/power-pages/scripts/tests/integration/discover-integration.test.js @@ -1,15 +1,16 @@ 'use strict'; -// Integration test for discover-site-components.js — runs against a real HTTP -// mock server (not injected makeRequest) so we validate the actual network -// code paths, URL construction, authorization header handling, and pagination. +// Integration test for discover-site-components.js against a real local HTTP +// server. Production rejects HTTP and non-Microsoft hosts before sending bearer +// tokens, so these tests inject the local-only transport while still exercising +// URL construction, authorization header handling, and pagination end to end. const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { startMock } = require('./mock-dataverse'); +const { makeLocalRequest, startMock } = require('./mock-dataverse'); const { discoverSiteComponents, } = require('../../lib/discover-site-components'); @@ -27,6 +28,40 @@ function makeSiteRoot(entityLogicalNames) { return root; } +function startHeaderMock() { + return startMock([ + { + method: 'GET', + matcher: '/headers', + headers: { 'x-test-header': 'present' }, + body: { ok: true }, + }, + ]); +} + +test('integration transport omits response headers by default', async () => { + const mock = await startHeaderMock(); + try { + const result = await makeLocalRequest({ url: `${mock.baseUrl}/headers` }); + assert.equal(Object.hasOwn(result, 'headers'), false); + } finally { + await mock.close(); + } +}); + +test('integration transport includes response headers when requested', async () => { + const mock = await startHeaderMock(); + try { + const result = await makeLocalRequest({ + url: `${mock.baseUrl}/headers`, + includeHeaders: true, + }); + assert.equal(result.headers['x-test-header'], 'present'); + } finally { + await mock.close(); + } +}); + test('integration: discover follows @odata.nextLink pagination against a real HTTP server', async () => { let mockBase = null; @@ -79,6 +114,7 @@ test('integration: discover follows @odata.nextLink pagination against a real HT token: 'fake-integration-token', siteId: 'site-42', solutionId: 'sol-integration', + makeRequest: makeLocalRequest, }); assert.equal(result.powerpagecomponents.total, 4, 'should aggregate both pages'); @@ -117,7 +153,7 @@ test('integration: discover surfaces HTTP 500 with a clear error', async () => { ]); try { await assert.rejects( - discoverSiteComponents({ envUrl: mock.baseUrl, token: 'x', siteId: 'site-42' }), + discoverSiteComponents({ envUrl: mock.baseUrl, token: 'x', siteId: 'site-42', makeRequest: makeLocalRequest }), /HTTP 500/ ); } finally { @@ -135,6 +171,7 @@ test('integration: discover survives an empty site (no components, no solutionId envUrl: mock.baseUrl, token: 'x', siteId: 'site-empty', + makeRequest: makeLocalRequest, }); assert.equal(result.powerpagecomponents.total, 0); assert.deepEqual(Object.keys(result.powerpagecomponents.byType), []); @@ -169,6 +206,7 @@ test('integration: countSolutionMembership cross-site safety check flags ppcs no 'sol-xyz', 'fake-token', sitePpcIdSet, + makeLocalRequest, ); assert.equal(result.total, 5); assert.equal(result.byComponentType[10373], 3); @@ -196,6 +234,7 @@ test('integration: countSolutionMembership returns empty crossSitePpcs when site 'sol-xyz', 'fake-token', null, + makeLocalRequest, ); assert.deepEqual(result.crossSitePpcs, [], 'no cross-site check when caller did not supply the site set'); @@ -257,6 +296,7 @@ test('integration: discover with publisherPrefix queries env vars + tables endpo siteId: 'site-42', publisherPrefix: 'contoso', projectRoot, + makeRequest: makeLocalRequest, }); assert.equal(result.envVars.length, 1); assert.equal(result.envVars[0].schemaName, 'contoso_FeatureFlag'); diff --git a/plugins/power-pages/scripts/tests/integration/mock-dataverse.js b/plugins/power-pages/scripts/tests/integration/mock-dataverse.js index 8f2247e0c..af5f086c5 100644 --- a/plugins/power-pages/scripts/tests/integration/mock-dataverse.js +++ b/plugins/power-pages/scripts/tests/integration/mock-dataverse.js @@ -15,6 +15,42 @@ const http = require('http'); +function makeLocalRequest({ + url, + method = 'GET', + headers = {}, + body = null, + includeHeaders = false, + timeout = 15000, +}) { + return new Promise((resolve) => { + const u = new URL(url); + const req = http.request({ + method, + headers, + hostname: u.hostname, + port: u.port || undefined, + path: u.pathname + u.search, + timeout, + }, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + const result = { statusCode: res.statusCode, body: data }; + if (includeHeaders) result.headers = res.headers; + resolve(result); + }); + }); + req.on('error', (error) => resolve({ error: error.message })); + req.on('timeout', () => { + req.destroy(); + resolve({ error: 'Request timed out' }); + }); + if (body) req.write(body); + req.end(); + }); +} + /** * Starts a localhost http server that responds to OData-like requests. * @@ -82,4 +118,4 @@ function routeMatches(route, method, url) { return false; } -module.exports = { startMock }; +module.exports = { makeLocalRequest, startMock }; diff --git a/plugins/power-pages/scripts/tests/launch-playwright-mcp.test.js b/plugins/power-pages/scripts/tests/launch-playwright-mcp.test.js index 0a5323fe5..d928b2418 100644 --- a/plugins/power-pages/scripts/tests/launch-playwright-mcp.test.js +++ b/plugins/power-pages/scripts/tests/launch-playwright-mcp.test.js @@ -5,28 +5,69 @@ const test = require('node:test'); const { EventEmitter } = require('node:events'); const { + PLAYWRIGHT_MCP_PACKAGE, buildMcpArgs, launch, - quoteShellArg, + resolveNpxCli, } = require('../launch-playwright-mcp'); -test('buildMcpArgs launches Playwright MCP with fullscreen config', () => { +test('buildMcpArgs launches the exact reviewed Playwright MCP version', () => { const expectedConfigPath = path.join(__dirname, '..', 'playwright-mcp-fullscreen.config.json'); const args = buildMcpArgs('chrome'); const configIndex = args.indexOf('--config'); - assert.deepEqual(args.slice(0, 4), ['-y', '@playwright/mcp@latest', '--browser', 'chrome']); + assert.equal(PLAYWRIGHT_MCP_PACKAGE, '@playwright/mcp@0.0.78'); + assert.deepEqual( + args.slice(0, 6), + [ + '--yes', + '--ignore-scripts', + '--package=@playwright/mcp@0.0.78', + 'playwright-mcp', + '--browser', + 'chrome', + ], + ); + assert.equal(args.some((arg) => /@(latest|next|\^|~|\*)$/.test(arg)), false); assert.equal(args.includes('--viewport-size'), false); assert.notEqual(configIndex, -1); - assert.equal(args[configIndex + 1], quoteShellArg(expectedConfigPath)); + assert.equal(args[configIndex + 1], expectedConfigPath); }); -test('buildMcpArgs quotes Windows config paths containing spaces', () => { - const configPath = 'C:\\Users\\Power User\\.claude\\plugins\\power-pages\\scripts\\playwright-mcp-fullscreen.config.json'; - const args = buildMcpArgs('msedge', { configPath, platform: 'win32' }); +test('buildMcpArgs preserves config paths with spaces and shell metacharacters as raw argv', () => { + const configPath = '/tmp/Power Pages $(echo unsafe); & [preview]/config\'s "quoted" path.json'; + const args = buildMcpArgs('chrome', { configPath }); const configIndex = args.indexOf('--config'); - assert.equal(args[configIndex + 1], `"${configPath}"`); + assert.equal(args[configIndex + 1], configPath); +}); + +test('buildMcpArgs preserves Windows config paths without shell quoting', () => { + const configPath = 'C:\\Users\\Power User & Team\\Power Pages (Preview)\\playwright-mcp.config.json'; + const args = buildMcpArgs('msedge', { configPath }); + const configIndex = args.indexOf('--config'); + + assert.equal(args[configIndex + 1], configPath); +}); + +test('resolveNpxCli finds the Windows npm JavaScript entrypoint', () => { + const expected = 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js'; + const checked = []; + + const resolved = resolveNpxCli({ + execPath: 'C:\\Program Files\\nodejs\\node.exe', + platform: 'win32', + existsSync(candidate) { + checked.push(candidate); + return candidate === expected; + }, + }); + + assert.equal(resolved, expected); + assert.deepEqual(checked, [ + 'C:\\Program Files\\lib\\node_modules\\npm\\bin\\npx-cli.js', + expected, + ]); }); test('fullscreen config maximizes the browser and uses the real viewport size', () => { @@ -37,25 +78,116 @@ test('fullscreen config maximizes the browser and uses the real viewport size', assert.equal(config.browser.contextOptions.viewport, null); }); -test('launch wires spawn and process exit handling', () => { +test('launch preserves an explicit npx CLI path and uses raw argv without a shell', () => { let spawnCall; const child = new EventEmitter(); launch({ browser: 'msedge', + npxCliPath: 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js', + resolveNpxCliFn() { + assert.fail('explicit npxCliPath must bypass default resolution'); + }, spawnFn(command, args, options) { spawnCall = { command, args, options }; return child; }, - onExit(code) { + exitFn(code) { spawnCall.exitCode = code; }, }); - assert.equal(spawnCall.command, 'npx'); - assert.deepEqual(spawnCall.args.slice(0, 4), ['-y', '@playwright/mcp@latest', '--browser', 'msedge']); - assert.deepEqual(spawnCall.options, { stdio: 'inherit', shell: true }); + assert.equal(spawnCall.command, process.execPath); + assert.deepEqual( + spawnCall.args.slice(0, 7), + [ + 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js', + '--yes', + '--ignore-scripts', + '--package=@playwright/mcp@0.0.78', + 'playwright-mcp', + '--browser', + 'msedge', + ], + ); + assert.deepEqual(spawnCall.options, { stdio: 'inherit', shell: false }); child.emit('exit', 7); assert.equal(spawnCall.exitCode, 7); }); + +test('launch reports missing npm once and does not spawn', () => { + const exits = []; + let spawnCalls = 0; + let stderr = ''; + + const child = launch({ + browser: 'chrome', + resolveNpxCliFn() { + throw new Error('Could not locate npm/bin/npx-cli.js'); + }, + spawnFn() { + spawnCalls += 1; + return new EventEmitter(); + }, + exitFn(code) { + exits.push(code); + }, + writeError(message) { + stderr += message; + }, + }); + + assert.equal(child, null); + assert.equal(spawnCalls, 0); + assert.deepEqual(exits, [1]); + assert.equal( + stderr, + 'Failed to start Playwright MCP: Could not locate npm/bin/npx-cli.js\n', + ); +}); + +test('launch reports spawn errors and exits with failure', () => { + const child = new EventEmitter(); + const exits = []; + let stderr = ''; + + launch({ + browser: 'chrome', + npxCliPath: '/trusted/npm/bin/npx-cli.js', + spawnFn() { + return child; + }, + exitFn(code) { + exits.push(code); + }, + writeError(message) { + stderr += message; + }, + }); + + child.emit('error', new Error('spawn ENOENT')); + + assert.deepEqual(exits, [1]); + assert.match(stderr, /^Failed to start Playwright MCP: spawn ENOENT\n$/); +}); + +test('launch treats signal-only child exits as failures', () => { + const child = new EventEmitter(); + let exitCode; + + launch({ + browser: 'chrome', + npxCliPath: '/trusted/npm/bin/npx-cli.js', + spawnFn() { + return child; + }, + exitFn(code) { + exitCode = code; + }, + }); + + child.emit('exit', null, 'SIGTERM'); + + assert.equal(exitCode, 1); +}); diff --git a/plugins/power-pages/scripts/tests/list-tenant-envs.test.js b/plugins/power-pages/scripts/tests/list-tenant-envs.test.js index 31b628443..0ccba6b54 100644 --- a/plugins/power-pages/scripts/tests/list-tenant-envs.test.js +++ b/plugins/power-pages/scripts/tests/list-tenant-envs.test.js @@ -22,7 +22,12 @@ function fakeEnv(overrides = {}) { test('originOf extracts scheme + host', () => { assert.equal(originOf('https://x.api.crm.dynamics.com/some/path'), 'https://x.api.crm.dynamics.com'); + assert.equal(originOf('https://ORG.API.CRM.DYNAMICS.COM/some/path'), 'https://org.api.crm.dynamics.com'); assert.equal(originOf('https://x.api.crm.dynamics.com'), 'https://x.api.crm.dynamics.com'); + assert.equal(originOf('https://x.api.crm.microsoftdynamics.us'), 'https://x.api.crm.microsoftdynamics.us'); + assert.equal(originOf('https://x.api.crm.appsplatform.us'), 'https://x.api.crm.appsplatform.us'); + assert.equal(originOf('https://x.api.crm.dynamics.cn'), 'https://x.api.crm.dynamics.cn'); + assert.equal(originOf('https://x.api.crm.dynamics.com.attacker.invalid'), null); assert.equal(originOf('not a url'), null); }); @@ -299,6 +304,28 @@ test('listTenantEnvs: token-acquisition failure marks env as inaccessible', asyn assert.match(result.inaccessibleEnvs[0].detail, /az failed/); }); +test('listTenantEnvs: rejects a malicious Dataverse-derived API host before token acquisition', async () => { + const envs = [ + fakeEnv({ + name: 'bad-host', + linkedEnvironmentMetadata: { + instanceUrl: 'https://bad-host.crm.dynamics.com/', + instanceApiUrl: 'https://bad-host.api.crm.dynamics.com.attacker.invalid', + }, + }), + ]; + let tokenCalls = 0; + const result = await listTenantEnvs({ + bapToken: 'fake', + listImpl: async () => envs, + getTokenImpl: () => { tokenCalls++; return 'token'; }, + verifyImpl: async () => { throw new Error('must not probe an untrusted host'); }, + }); + + assert.equal(tokenCalls, 0); + assert.equal(result.inaccessibleEnvs[0].reason, 'invalid-instance-api-url'); +}); + test('listTenantEnvs: throws when --source bap and --bapToken is missing', async () => { await assert.rejects( () => listTenantEnvs({ source: 'bap' }), diff --git a/plugins/power-pages/scripts/tests/mcp-config.test.js b/plugins/power-pages/scripts/tests/mcp-config.test.js index 8c2557efe..f24194aeb 100644 --- a/plugins/power-pages/scripts/tests/mcp-config.test.js +++ b/plugins/power-pages/scripts/tests/mcp-config.test.js @@ -6,38 +6,268 @@ const { spawnSync } = require('node:child_process'); const test = require('node:test'); const pluginRoot = path.resolve(__dirname, '..', '..'); +const config = JSON.parse(fs.readFileSync(path.join(pluginRoot, '.mcp.json'), 'utf8')); +const server = config.mcpServers.playwright; -function createFakeNpx(dir) { - const commandPath = path.join(dir, process.platform === 'win32' ? 'npx.cmd' : 'npx'); - const script = process.platform === 'win32' - ? '@echo off\r\necho fake-npx %*\r\n' - : '#!/bin/sh\necho "fake-npx $*"\n'; +function createSpawnPreload(dir) { + const preloadPath = path.join(dir, 'intercept-spawn.js'); + fs.writeFileSync(preloadPath, ` +const { EventEmitter } = require('node:events'); +const fs = require('node:fs'); +const realExistsSync = fs.existsSync; +fs.existsSync = (candidate) => { + if (String(candidate).replaceAll('\\\\', '/').endsWith('/npm/bin/npx-cli.js')) { + process.stdout.write('fake-npx-cli ' + candidate + '\\n'); + return true; + } + return realExistsSync(candidate); +}; +require('node:child_process').spawn = (command, args, options) => { + process.stdout.write('fake-spawn ' + JSON.stringify({ command, args, options }) + '\\n'); + const child = new EventEmitter(); + process.nextTick(() => child.emit('exit', 0)); + return child; +}; +`); + return preloadPath; +} + +function runBootstrap({ + cwd, + pluginRoot: pluginRootValue, + claudePluginRoot, + preloadPath, + realpathOverride, + statFailure, +} = {}) { + const env = { ...process.env }; + let args = [...server.args]; + delete env.PLUGIN_ROOT; + delete env.CLAUDE_PLUGIN_ROOT; + + if (pluginRootValue !== undefined) { + env.PLUGIN_ROOT = pluginRootValue; + } + if (claudePluginRoot !== undefined) { + env.CLAUDE_PLUGIN_ROOT = claudePluginRoot; + } + if (realpathOverride) { + // Patch only the launcher lookup so the canonical root still follows the real filesystem. + const bootstrapIndex = args.indexOf('-e') + 1; + const prelude = [ + "const injectedFs=require('node:fs');", + 'const originalRealpathSync=injectedFs.realpathSync;', + `const injectedRealpathTarget=${JSON.stringify(path.resolve(realpathOverride.target))};`, + `const injectedRealpathResult=${JSON.stringify(path.resolve(realpathOverride.result))};`, + "injectedFs.realpathSync=function(target,...options){if(require('node:path').resolve(String(target))===injectedRealpathTarget)return injectedRealpathResult;return originalRealpathSync.call(this,target,...options);};", + ].join(' '); + args[bootstrapIndex] = `${prelude} ${args[bootstrapIndex]}`; + } + if (statFailure) { + // Patch the child process's fs module so access errors are deterministic across platforms. + const bootstrapIndex = args.indexOf('-e') + 1; + const prelude = [ + "const injectedFs=require('node:fs');", + 'const originalStatSync=injectedFs.statSync;', + `const injectedStatTarget=${JSON.stringify(path.resolve(statFailure.target))};`, + `const injectedStatCode=${JSON.stringify(statFailure.code)};`, + "injectedFs.statSync=function(target,...options){if(require('node:path').resolve(String(target))===injectedStatTarget){const error=new Error('injected statSync failure');error.code=injectedStatCode;throw error;}return originalStatSync.call(this,target,...options);};", + ].join(' '); + args[bootstrapIndex] = `${prelude} ${args[bootstrapIndex]}`; + } - fs.writeFileSync(commandPath, script, { mode: 0o755 }); - return commandPath; + if (preloadPath) { + args = ['--require', preloadPath, ...args]; + } + + return spawnSync(server.command, args, { + cwd, + encoding: 'utf8', + env, + timeout: 5_000, + }); } -test('playwright MCP bootstrap resolves the plugin root without host-provided env vars', (t) => { +test('playwright MCP bootstrap wraps root stat errors with a clear diagnostic', () => { + const root = fs.realpathSync(pluginRoot); + const result = runBootstrap({ + cwd: pluginRoot, + pluginRoot, + statFailure: { target: root, code: 'EACCES' }, + }); + + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /\[Power Pages Playwright MCP\] Could not inspect declared plugin root: .+ \(EACCES\)\./, + ); +}); + +test('playwright MCP bootstrap wraps launcher stat errors with a clear diagnostic', () => { + const launcher = fs.realpathSync(path.join(pluginRoot, 'scripts', 'launch-playwright-mcp.js')); + const result = runBootstrap({ + cwd: pluginRoot, + pluginRoot, + statFailure: { target: launcher, code: 'EPERM' }, + }); + + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /\[Power Pages Playwright MCP\] Could not inspect resolved launcher: .+ \(EPERM\)\./, + ); +}); + +test('playwright MCP bootstrap requires a host-provided plugin root', () => { + const result = runBootstrap({ cwd: pluginRoot }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT must be set/); + assert.match(result.stderr, /refusing to resolve the launcher from the current working directory/); +}); + +test('playwright MCP bootstrap does not execute a launcher from a malicious cwd', (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-malicious-cwd-')); + const scriptsDir = path.join(tempDir, 'scripts'); + const markerPath = path.join(tempDir, 'cwd-launcher-executed'); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + + fs.mkdirSync(scriptsDir); + fs.writeFileSync( + path.join(scriptsDir, 'launch-playwright-mcp.js'), + `require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'executed'); module.exports = { launch() {} };\n`, + ); + + const result = runBootstrap({ cwd: tempDir }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT must be set/); + assert.equal(fs.existsSync(markerPath), false); +}); + +test('playwright MCP bootstrap rejects malformed plugin roots', async (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-invalid-root-')); + const fileRoot = path.join(tempDir, 'not-a-directory'); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + fs.writeFileSync(fileRoot, 'not a plugin root'); + + await t.test('nonexistent root', () => { + const result = runBootstrap({ + cwd: tempDir, + pluginRoot: path.join(tempDir, 'missing'), + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Declared plugin root is invalid/); + }); + + await t.test('file root', () => { + const result = runBootstrap({ cwd: tempDir, pluginRoot: fileRoot }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Declared plugin root is not a directory/); + }); + + await t.test('relative root', () => { + const result = runBootstrap({ cwd: tempDir, pluginRoot: '.' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Declared plugin root must be an absolute path/); + }); +}); + +test('playwright MCP bootstrap rejects a launcher that resolves outside the plugin root', (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-escape-')); + const declaredRoot = path.join(tempDir, 'plugin'); + const outsideScripts = path.join(tempDir, 'outside-scripts'); + const markerPath = path.join(tempDir, 'escaped-launcher-executed'); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + + fs.mkdirSync(declaredRoot); + fs.mkdirSync(outsideScripts); + fs.writeFileSync( + path.join(outsideScripts, 'launch-playwright-mcp.js'), + `require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'executed'); module.exports = { launch() {} };\n`, + ); + + try { + fs.symlinkSync( + outsideScripts, + path.join(declaredRoot, 'scripts'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch (error) { + if (error.code === 'EPERM' || error.code === 'EACCES') { + t.skip(`symlinks are unavailable: ${error.code}`); + return; + } + throw error; + } + + const result = runBootstrap({ cwd: tempDir, pluginRoot: declaredRoot }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Resolved launcher escapes the declared plugin root/); + assert.equal(fs.existsSync(markerPath), false); +}); + +test('playwright MCP bootstrap rejects a launcher resolving to the exact parent directory', (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-parent-escape-')); + const declaredRoot = path.join(tempDir, 'plugin'); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + fs.mkdirSync(declaredRoot); + const canonicalRoot = fs.realpathSync(declaredRoot); + const candidate = path.join(canonicalRoot, 'scripts', 'launch-playwright-mcp.js'); + const exactParent = path.dirname(canonicalRoot); + + const result = runBootstrap({ + cwd: tempDir, + pluginRoot: declaredRoot, + realpathOverride: { target: candidate, result: exactParent }, + }); + + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /Error: \[Power Pages Playwright MCP\] Resolved launcher escapes the declared plugin root:/, + ); + assert.doesNotMatch( + result.stderr, + /Error: \[Power Pages Playwright MCP\] Resolved launcher is not a file:/, + ); +}); + +test('playwright MCP bootstrap supports installed-plugin root environment conventions', async (t) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-')); t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); - createFakeNpx(tempDir); + const preloadPath = createSpawnPreload(tempDir); - const config = JSON.parse(fs.readFileSync(path.join(pluginRoot, '.mcp.json'), 'utf8')); - const server = config.mcpServers.playwright; - const pathSeparator = process.platform === 'win32' ? ';' : ':'; - const result = spawnSync(server.command, server.args, { - cwd: pluginRoot, - encoding: 'utf8', - env: { - HOME: process.env.HOME, - PATH: `${tempDir}${pathSeparator}${process.env.PATH || ''}`, - USERPROFILE: process.env.USERPROFILE, - }, - timeout: 5_000, + await t.test('PLUGIN_ROOT', () => { + const result = runBootstrap({ + cwd: tempDir, + pluginRoot, + preloadPath, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /fake-npx-cli/); + assert.match(result.stdout, /fake-spawn/); + assert.match(result.stdout, /--package=@playwright\/mcp@0\.0\.78/); + assert.match(result.stdout, /"shell":false/); }); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /fake-npx/); - assert.doesNotMatch(result.stderr, /PLUGIN_ROOT is not set/); + await t.test('CLAUDE_PLUGIN_ROOT', () => { + const result = runBootstrap({ + cwd: tempDir, + claudePluginRoot: pluginRoot, + preloadPath, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /fake-npx-cli/); + assert.match(result.stdout, /fake-spawn/); + assert.match(result.stdout, /--package=@playwright\/mcp@0\.0\.78/); + assert.match(result.stdout, /"shell":false/); + }); }); diff --git a/plugins/power-pages/scripts/tests/provision-custom-host.test.js b/plugins/power-pages/scripts/tests/provision-custom-host.test.js index 1efbb667f..80a6737b8 100644 --- a/plugins/power-pages/scripts/tests/provision-custom-host.test.js +++ b/plugins/power-pages/scripts/tests/provision-custom-host.test.js @@ -401,7 +401,7 @@ test('captures Location header in result for diagnostics', async (t) => { match: (u, args) => args.method === 'POST', respond: () => ({ statusCode: 202, - headers: { location: 'https://api.bap.microsoft.com/lifecycleOperations/abc', 'retry-after': '1' }, + headers: { location: '/lifecycleOperations/abc', 'retry-after': '1' }, body: JSON.stringify({ name: 'e1', properties: { provisioningState: 'Creating' } }), }), }, @@ -415,6 +415,38 @@ test('captures Location header in result for diagnostics', async (t) => { assert.equal(result.locationHeader, 'https://api.bap.microsoft.com/lifecycleOperations/abc'); }); +test('rejects a cross-cloud Location header before Custom Host polling', async (t) => { + let pollCalled = false; + withMockedHttp(t, [ + { + match: (u, args) => args.method === 'POST', + respond: () => ({ + statusCode: 202, + headers: { location: 'https://dod.api.bap.microsoft.us/lifecycleOperations/custom-cross-cloud' }, + body: JSON.stringify({ name: 'e1', properties: { provisioningState: 'Creating' } }), + }), + }, + { + match: () => true, + respond: () => { + pollCalled = true; + return { statusCode: 200, body: '{}' }; + }, + }, + ]); + + await assert.rejects( + () => provisionCustomHost({ + bapToken: 'fake', + displayName: 'X', + region: 'unitedstates', + sleepImpl: noSleep, + }), + /Location header.*different host/, + ); + assert.equal(pollCalled, false); +}); + test('falls back to env GET when lifecycle op response lacks linkedEnvironmentMetadata', async (t) => { withMockedHttp(t, [ { diff --git a/plugins/power-pages/scripts/tests/provision-platform-host.test.js b/plugins/power-pages/scripts/tests/provision-platform-host.test.js index 3a135ae6e..9f97c961e 100644 --- a/plugins/power-pages/scripts/tests/provision-platform-host.test.js +++ b/plugins/power-pages/scripts/tests/provision-platform-host.test.js @@ -359,7 +359,7 @@ test('captures Location header in result for diagnostics', async (t) => { match: (u, args) => args.method === 'POST', respond: () => ({ statusCode: 202, - headers: { location: 'https://api.bap.microsoft.com/lifecycleOperations/pe-loc', 'retry-after': '1' }, + headers: { location: '/lifecycleOperations/pe-loc', 'retry-after': '1' }, body: JSON.stringify({ name: 'e1', properties: { provisioningState: 'Creating' } }), }), }, @@ -374,6 +374,33 @@ test('captures Location header in result for diagnostics', async (t) => { assert.equal(result.alreadyExisted, false); }); +test('rejects a cross-cloud Location header before Platform Host polling', async (t) => { + let pollCalled = false; + withMockedHttp(t, [ + { + match: (u, args) => args.method === 'POST', + respond: () => ({ + statusCode: 202, + headers: { location: 'https://high.api.bap.microsoft.us/lifecycleOperations/pe-cross-cloud' }, + body: JSON.stringify({ name: 'e1', properties: { provisioningState: 'Creating' } }), + }), + }, + { + match: () => true, + respond: () => { + pollCalled = true; + return { statusCode: 200, body: '{}' }; + }, + }, + ]); + + await assert.rejects( + () => provisionPlatformHost({ bapToken: 'fake', sleepImpl: noSleep }), + /Location header.*different host/, + ); + assert.equal(pollCalled, false); +}); + test('falls back to env GET when lifecycle op response lacks linkedEnvironmentMetadata', async (t) => { withMockedHttp(t, [ { diff --git a/plugins/power-pages/scripts/tests/run-skill-posttool-validation.test.js b/plugins/power-pages/scripts/tests/run-skill-posttool-validation.test.js index f15bb402c..fea44f348 100644 --- a/plugins/power-pages/scripts/tests/run-skill-posttool-validation.test.js +++ b/plugins/power-pages/scripts/tests/run-skill-posttool-validation.test.js @@ -45,14 +45,54 @@ function backdatePlan(root, secondsAgo = 60) { fs.utimesSync(p, ts, ts); } -function runHook(root, skill) { +function runHook(root, skill, env = {}) { return spawnSync(process.execPath, [HOOK_PATH], { input: JSON.stringify({ tool_input: { skill }, cwd: root }), encoding: 'utf8', cwd: root, + env: { ...process.env, ...env }, }); } +function nodeRequireOption(filePath) { + // NODE_OPTIONS tokenization treats Windows backslashes as escapes. Forward + // slashes remain valid in absolute Windows paths and survive on all runners. + return `--require "${filePath.replace(/\\/g, '/').replace(/"/g, '\\"')}"`; +} + +test('activate-site validator passes a metacharacter project path literally to its child', (t) => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-activation-')); + const root = path.join(parent, 'site-$ACTIVATION_PATH_PROBE-%ACTIVATION_PATH_PROBE%-&'); + const preloadPath = path.join(parent, 'capture-activation-argv.cjs'); + const capturePath = path.join(parent, 'captured-project-root.txt'); + fs.mkdirSync(root); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + + writeJson(path.join(root, 'powerpages.config.json'), { + siteName: 'Activation path test', + websiteRecordId: '00000000-0000-0000-0000-000000000001', + }); + fs.writeFileSync(preloadPath, ` +const fs = require('fs'); +const path = require('path'); +if (path.basename(process.argv[1] || '') === 'check-activation-status.js') { + const projectRootIndex = process.argv.indexOf('--projectRoot'); + fs.writeFileSync(process.env.ACTIVATION_ARG_CAPTURE, process.argv[projectRootIndex + 1], 'utf8'); + process.stdout.write(JSON.stringify({ activated: true, siteName: 'Activation path test' })); + process.exit(0); +} +`, 'utf8'); + + const res = runHook(root, 'activate-site', { + ACTIVATION_ARG_CAPTURE: capturePath, + ACTIVATION_PATH_PROBE: 'expanded-by-a-shell', + NODE_OPTIONS: nodeRequireOption(preloadPath), + }); + + assert.equal(res.status, 0, `hook should approve; stderr=${res.stderr}`); + assert.equal(fs.readFileSync(capturePath, 'utf8'), root); +}); + test('hook spawns the reconcile backstop and heals a skipped refresh after an ALM skill', (t) => { const root = makeProject(t); writeJson(path.join(root, 'docs', '.alm-plan-data.json'), { diff --git a/plugins/power-pages/scripts/tests/validate-export.test.js b/plugins/power-pages/scripts/tests/validate-export.test.js new file mode 100644 index 000000000..e45926fc3 --- /dev/null +++ b/plugins/power-pages/scripts/tests/validate-export.test.js @@ -0,0 +1,280 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const zlib = require('node:zlib'); +const { spawnSync } = require('node:child_process'); + +const VALIDATOR_PATH = path.join( + __dirname, + '..', + '..', + 'skills', + 'export-solution', + 'scripts', + 'validate-export.js' +); + +function crc32(data) { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createZip(entries) { + const localRecords = []; + const centralRecords = []; + let localOffset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name, 'utf8'); + const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8'); + const method = entry.method ?? 0; + const compressedData = method === 8 ? zlib.deflateRawSync(data) : data; + const checksum = crc32(data); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(0x0800, 6); + localHeader.writeUInt16LE(method, 8); + localHeader.writeUInt32LE(checksum, 14); + localHeader.writeUInt32LE(compressedData.length, 18); + localHeader.writeUInt32LE(data.length, 22); + localHeader.writeUInt16LE(name.length, 26); + localRecords.push(localHeader, name, compressedData); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(0x0800, 8); + centralHeader.writeUInt16LE(method, 10); + centralHeader.writeUInt32LE(checksum, 16); + centralHeader.writeUInt32LE(compressedData.length, 20); + centralHeader.writeUInt32LE(data.length, 24); + centralHeader.writeUInt16LE(name.length, 28); + centralHeader.writeUInt32LE(localOffset, 42); + centralRecords.push(centralHeader, name); + + localOffset += localHeader.length + name.length + compressedData.length; + } + + const centralDirectory = Buffer.concat(centralRecords); + const endRecord = Buffer.alloc(22); + endRecord.writeUInt32LE(0x06054b50, 0); + endRecord.writeUInt16LE(entries.length, 8); + endRecord.writeUInt16LE(entries.length, 10); + endRecord.writeUInt32LE(centralDirectory.length, 12); + endRecord.writeUInt32LE(localOffset, 16); + + return Buffer.concat([...localRecords, centralDirectory, endRecord]); +} + +function makeProject(t) { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-export-')); + t.after(() => fs.rmSync(projectRoot, { recursive: true, force: true })); + return projectRoot; +} + +function validSolutionEntries(extraEntries = []) { + return [ + { + name: 'Solution.xml', + data: `${crypto.randomBytes(1400).toString('hex')}`, + method: 8, + }, + ...extraEntries, + ]; +} + +function runValidator(projectRoot, env = process.env) { + return spawnSync(process.execPath, [VALIDATOR_PATH], { + cwd: projectRoot, + input: JSON.stringify({ cwd: projectRoot }), + encoding: 'utf8', + timeout: 10000, + env, + }); +} + +test('approves when no exported solution ZIP exists', (t) => { + const projectRoot = makeProject(t); + const result = runValidator(projectRoot); + + assert.equal(result.status, 0, result.stderr); +}); + +test('approves a valid deflated solution ZIP containing Solution.xml', (t) => { + const projectRoot = makeProject(t); + fs.writeFileSync( + path.join(projectRoot, 'release_unmanaged.zip'), + createZip(validSolutionEntries()) + ); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 0, result.stderr); +}); + +test('handles malicious archive and path names without invoking a shell', (t) => { + const projectRoot = makeProject(t); + const markerName = 'validator-command-ran'; + const zipName = `release_$(touch ${markerName})_unmanaged.zip`; + fs.writeFileSync( + path.join(projectRoot, zipName), + createZip(validSolutionEntries([ + { + name: 'assets/"quoted"; $(ignored) & payload.txt', + data: 'not executable', + }, + ])) + ); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.existsSync(path.join(projectRoot, markerName)), false); +}); + +test('handles spaces, quotes, and cross-platform filename metacharacters', (t) => { + const projectRoot = makeProject(t); + const zipPath = path.join(projectRoot, "release 'review copy' & (final); 100%_managed.zip"); + fs.writeFileSync(zipPath, createZip(validSolutionEntries())); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 0, result.stderr); +}); + +test('blocks a corrupt ZIP instead of approving by file size', (t) => { + const projectRoot = makeProject(t); + fs.writeFileSync( + path.join(projectRoot, 'corrupt_unmanaged.zip'), + crypto.randomBytes(1500) + ); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 2); + assert.match(result.stderr, /could not be validated/i); +}); + +test('blocks a structurally valid ZIP that does not contain Solution.xml', (t) => { + const projectRoot = makeProject(t); + fs.writeFileSync( + path.join(projectRoot, 'missing_solution_managed.zip'), + createZip([ + { + name: 'Other.xml', + data: crypto.randomBytes(1400), + }, + ]) + ); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 2); + assert.match(result.stderr, /does not contain solution\.xml/i); +}); + +test('blocks a ZIP when the Solution.xml payload fails its integrity check', (t) => { + const projectRoot = makeProject(t); + const archive = createZip([ + { + name: 'Solution.xml', + data: crypto.randomBytes(1400), + method: 0, + }, + ]); + archive[30 + Buffer.byteLength('Solution.xml')] ^= 0xff; + fs.writeFileSync(path.join(projectRoot, 'damaged_managed.zip'), archive); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 2); + assert.match(result.stderr, /integrity check/i); +}); + +test('blocks duplicate root Solution.xml entries', (t) => { + const projectRoot = makeProject(t); + fs.writeFileSync( + path.join(projectRoot, 'duplicate_unmanaged.zip'), + createZip([ + ...validSolutionEntries(), + { + name: 'solution.XML', + data: crypto.randomBytes(1400), + }, + ]) + ); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 2); + assert.match(result.stderr, /duplicate Solution\.xml/i); +}); + +test('blocks a ZIP with a corrupt non-manifest local header', (t) => { + const projectRoot = makeProject(t); + const archive = createZip(validSolutionEntries([ + { + name: 'Customizations.xml', + data: crypto.randomBytes(1400), + }, + ])); + const secondLocalHeader = archive.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]), 4); + archive.writeUInt32LE(0, secondLocalHeader); + fs.writeFileSync(path.join(projectRoot, 'corrupt_entry_managed.zip'), archive); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 2); + assert.match(result.stderr, /local header.*invalid signature/i); +}); + +test('blocks local-header metadata that disagrees with the central directory', (t) => { + const projectRoot = makeProject(t); + const archive = createZip(validSolutionEntries()); + archive.writeUInt32LE(1, 18); + fs.writeFileSync(path.join(projectRoot, 'corrupt_metadata_managed.zip'), archive); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 2); + assert.match(result.stderr, /sizes or checksum.*disagree/i); +}); + +test('blocks oversized archives before reading them into memory', (t) => { + const projectRoot = makeProject(t); + const zipPath = path.join(projectRoot, 'oversized_unmanaged.zip'); + fs.writeFileSync(zipPath, Buffer.alloc(0)); + fs.truncateSync(zipPath, 100 * 1024 * 1024 + 1); + + const result = runValidator(projectRoot); + + assert.equal(result.status, 2); + assert.match(result.stderr, /exceeds the supported 100 MiB/i); +}); + +test('validates without unzip, grep, or any executable available on PATH', (t) => { + const projectRoot = makeProject(t); + fs.writeFileSync( + path.join(projectRoot, 'windows-compatible_unmanaged.zip'), + createZip(validSolutionEntries()) + ); + + const envWithoutPath = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'path') + ); + const result = runValidator(projectRoot, { ...envWithoutPath, PATH: '' }); + + assert.equal(result.status, 0, result.stderr); +}); diff --git a/plugins/power-pages/scripts/tests/validation-helpers.test.js b/plugins/power-pages/scripts/tests/validation-helpers.test.js index d8fcc1ba6..4f8526979 100644 --- a/plugins/power-pages/scripts/tests/validation-helpers.test.js +++ b/plugins/power-pages/scripts/tests/validation-helpers.test.js @@ -6,18 +6,18 @@ const childProcess = require('child_process'); const helpersPath = path.join(__dirname, '..', 'lib', 'validation-helpers.js'); test('getAuthToken calls az account get-access-token without --allow-no-subscriptions (only az login accepts that flag)', (t) => { - const originalExecSync = childProcess.execSync; - let capturedCommand = null; + const originalExecFileSync = childProcess.execFileSync; + let captured = null; - childProcess.execSync = (command, options) => { - capturedCommand = command; + childProcess.execFileSync = (file, args, options) => { + captured = { file, args, options }; const out = 'fake-token-value\n'; return options && options.encoding ? out : Buffer.from(out); }; delete require.cache[require.resolve(helpersPath)]; t.after(() => { - childProcess.execSync = originalExecSync; + childProcess.execFileSync = originalExecFileSync; delete require.cache[require.resolve(helpersPath)]; }); @@ -25,13 +25,130 @@ test('getAuthToken calls az account get-access-token without --allow-no-subscrip const token = getAuthToken('https://example.crm.dynamics.com'); assert.equal(token, 'fake-token-value'); - assert.match(capturedCommand, /^az account get-access-token /); - assert.doesNotMatch( - capturedCommand, - /--allow-no-subscriptions/, + assert.equal(captured.file, 'az'); + assert.deepEqual( + captured.args, + ['account', 'get-access-token', '--resource', 'https://example.crm.dynamics.com', '--query', 'accessToken', '-o', 'tsv'], + ); + assert.equal(captured.options.shell, false); + assert.ok( + !captured.args.includes('--allow-no-subscriptions'), 'az account get-access-token rejects --allow-no-subscriptions on recent CLI versions; the helper must omit it.', ); - assert.match(capturedCommand, /--resource "https:\/\/example\.crm\.dynamics\.com"/); +}); + +test('getAuthToken rejects POSIX and Windows metacharacter payloads before invoking az', (t) => { + const originalExecFileSync = childProcess.execFileSync; + let calls = 0; + childProcess.execFileSync = () => { + calls++; + return 'should-not-run'; + }; + delete require.cache[require.resolve(helpersPath)]; + t.after(() => { + childProcess.execFileSync = originalExecFileSync; + delete require.cache[require.resolve(helpersPath)]; + }); + + const { getAuthToken } = require(helpersPath); + assert.equal(getAuthToken('https://org.crm.dynamics.com/;echo-marker'), null); + assert.equal(getAuthToken('https://org.crm.dynamics.com/&echo-marker%PATH%'), null); + assert.equal(calls, 0); +}); + +test('URL validation accepts documented Dataverse and Power Platform sovereign-cloud hosts', () => { + const { + validateDataverseEnvironmentUrl, + validateTokenResourceUrl, + validateBapUrl, + validateBapPollingUrl, + } = require(helpersPath); + + const dataverseUrls = [ + 'https://org.crm9.dynamics.com', + 'https://org.api.crm.microsoftdynamics.us', + 'https://org.api.crm.appsplatform.us', + 'https://org.api.crm.dynamics.cn', + ]; + for (const url of dataverseUrls) { + assert.equal(validateDataverseEnvironmentUrl(url), url); + } + assert.equal( + validateDataverseEnvironmentUrl('HTTPS://ORG.CRM.DYNAMICS.COM'), + 'https://org.crm.dynamics.com', + ); + assert.equal( + validateDataverseEnvironmentUrl('HtTpS://Org.Api.Crm.MicrosoftDynamics.Us'), + 'https://org.api.crm.microsoftdynamics.us', + ); + + assert.equal( + validateTokenResourceUrl('https://high.service.flow.microsoft.us/'), + 'https://high.service.flow.microsoft.us/', + ); + assert.equal( + validateTokenResourceUrl('https://high.gov.service.flow.microsoft.us/'), + 'https://high.gov.service.flow.microsoft.us/', + ); + assert.equal( + validateTokenResourceUrl('https://api.powerplatform.partner.microsoftonline.cn'), + 'https://api.powerplatform.partner.microsoftonline.cn', + ); + assert.equal( + validateBapUrl('https://dod.api.bap.microsoft.us/providers/example'), + 'https://dod.api.bap.microsoft.us/providers/example', + ); + assert.equal( + validateBapPollingUrl( + '/providers/Microsoft.BusinessAppPlatform/lifecycleOperations/op-1', + 'https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments', + ), + 'https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/lifecycleOperations/op-1', + ); + assert.equal( + validateBapPollingUrl( + 'https://api.bap.microsoft.com/lifecycleOperations/op-2', + 'https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments', + ), + 'https://api.bap.microsoft.com/lifecycleOperations/op-2', + ); + assert.throws( + () => validateBapPollingUrl( + 'https://high.api.bap.microsoft.us/lifecycleOperations/op-3', + 'https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments', + ), + /different host/, + ); +}); + +test('URL validation rejects malicious hosts, credentials, ports, fragments, and unsafe host characters', async () => { + const { + validateDataverseEnvironmentUrl, + validateTokenResourceUrl, + makeRequest, + } = require(helpersPath); + + const invalid = [ + 'http://org.crm.dynamics.com', + 'https://user:pass@org.crm.dynamics.com', + 'https://org.crm.dynamics.com:443', + 'HTTPS://org.crm.dynamics.com:443', + 'https://org.crm.dynamics.com#fragment', + 'https://org.crm.dynamics.com.attacker.invalid', + 'https://org_crm.dynamics.com', + 'https://org.crm.dynamics.com\n.attacker.invalid', + ]; + for (const url of invalid) { + assert.throws(() => validateDataverseEnvironmentUrl(url)); + } + assert.throws(() => validateTokenResourceUrl('https://example.invalid')); + assert.throws( + () => makeRequest({ + url: 'https://metadata.internal.invalid/token', + headers: { Authorization: 'Bearer test-token' }, + }), + /not an allowed Microsoft Dataverse or Power Platform endpoint/, + ); }); // --- findProjectRoot: EDM / data-model site awareness ------------------------ @@ -151,6 +268,6 @@ test('getEnvironmentUrl parses the 2.8.x "Org URL:" output via mocked execSync', // Re-require fresh so the module binds the mocked execSync. delete require.cache[require.resolve(helpersPath)]; const { getEnvironmentUrl } = require(helpersPath); - assert.equal(getEnvironmentUrl(), 'https://orgABC.crm.dynamics.com'); + assert.equal(getEnvironmentUrl(), 'https://orgabc.crm.dynamics.com'); delete require.cache[require.resolve(helpersPath)]; }); diff --git a/plugins/power-pages/skills/activate-site/scripts/validate-activation.js b/plugins/power-pages/skills/activate-site/scripts/validate-activation.js index e6c97182f..dcc338eb7 100644 --- a/plugins/power-pages/skills/activate-site/scripts/validate-activation.js +++ b/plugins/power-pages/skills/activate-site/scripts/validate-activation.js @@ -6,7 +6,7 @@ // instead of relying on an intermediate file. const path = require('path'); -const { execSync } = require('child_process'); +const { execFileSync } = require('child_process'); const { approve, block, runValidation, findPath } = require('../../../scripts/lib/validation-helpers'); runValidation(async (cwd) => { @@ -18,9 +18,10 @@ runValidation(async (cwd) => { let result; try { - const output = execSync(`node "${checkScript}" --projectRoot "${projectRoot}"`, { + const output = execFileSync(process.execPath, [checkScript, '--projectRoot', projectRoot], { encoding: 'utf8', timeout: 30000, + shell: false, }); result = JSON.parse(output); } catch { diff --git a/plugins/power-pages/skills/export-solution/scripts/validate-export.js b/plugins/power-pages/skills/export-solution/scripts/validate-export.js index fd34487ca..9571dac59 100644 --- a/plugins/power-pages/skills/export-solution/scripts/validate-export.js +++ b/plugins/power-pages/skills/export-solution/scripts/validate-export.js @@ -6,8 +6,234 @@ const fs = require('fs'); const path = require('path'); -const { execSync } = require('child_process'); -const { approve, block, runValidation, findProjectRoot, findPath, readDeferralMarker } = require('../../../scripts/lib/validation-helpers'); +const zlib = require('zlib'); +const { approve, block, runValidation, findProjectRoot, readDeferralMarker } = require('../../../scripts/lib/validation-helpers'); + +const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; +const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50; +const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; +const MAX_ZIP_COMMENT_LENGTH = 0xffff; +const MIN_END_OF_CENTRAL_DIRECTORY_SIZE = 22; +const MAX_SOLUTION_ZIP_SIZE = 100 * 1024 * 1024; +const MAX_SOLUTION_XML_SIZE = 100 * 1024 * 1024; + +function findEndOfCentralDirectory(archive) { + const firstPossibleOffset = Math.max( + 0, + archive.length - MIN_END_OF_CENTRAL_DIRECTORY_SIZE - MAX_ZIP_COMMENT_LENGTH + ); + + for (let offset = archive.length - MIN_END_OF_CENTRAL_DIRECTORY_SIZE; offset >= firstPossibleOffset; offset--) { + if (archive.readUInt32LE(offset) !== END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue; + + const commentLength = archive.readUInt16LE(offset + 20); + if (offset + MIN_END_OF_CENTRAL_DIRECTORY_SIZE + commentLength === archive.length) { + return offset; + } + } + + throw new Error('the ZIP end-of-central-directory record is missing'); +} + +/** + * Reads ZIP entry metadata without extracting files. + * + * ZIP archives end with an EOCD record that points to central-directory entries: + * 0x02014b50 | metadata | name length | extra length | comment length | file name + * The offsets and lengths are untrusted, so every read is bounded before use. ZIP64 + * and multi-disk archives are rejected because Power Platform solution packages do + * not need either format and partially parsing them could approve a malformed file. + * See: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT + */ +function readZipEntries(archive) { + const eocdOffset = findEndOfCentralDirectory(archive); + const diskNumber = archive.readUInt16LE(eocdOffset + 4); + const centralDirectoryDisk = archive.readUInt16LE(eocdOffset + 6); + const entriesOnDisk = archive.readUInt16LE(eocdOffset + 8); + const totalEntries = archive.readUInt16LE(eocdOffset + 10); + const centralDirectorySize = archive.readUInt32LE(eocdOffset + 12); + const centralDirectoryOffset = archive.readUInt32LE(eocdOffset + 16); + + if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries) { + throw new Error('multi-disk ZIP archives are not supported'); + } + if ( + totalEntries === 0xffff || + centralDirectorySize === 0xffffffff || + centralDirectoryOffset === 0xffffffff + ) { + throw new Error('ZIP64 archives are not supported'); + } + + const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize; + if (centralDirectoryEnd > eocdOffset || centralDirectoryEnd > archive.length) { + throw new Error('the ZIP central directory is outside the archive'); + } + + const entries = []; + let offset = centralDirectoryOffset; + for (let index = 0; index < totalEntries; index++) { + if (offset + 46 > centralDirectoryEnd) { + throw new Error('a ZIP central-directory entry is truncated'); + } + if (archive.readUInt32LE(offset) !== CENTRAL_DIRECTORY_HEADER_SIGNATURE) { + throw new Error('a ZIP central-directory entry has an invalid signature'); + } + + const flags = archive.readUInt16LE(offset + 8); + const compressionMethod = archive.readUInt16LE(offset + 10); + const crc = archive.readUInt32LE(offset + 16); + const compressedSize = archive.readUInt32LE(offset + 20); + const uncompressedSize = archive.readUInt32LE(offset + 24); + const fileNameLength = archive.readUInt16LE(offset + 28); + const extraFieldLength = archive.readUInt16LE(offset + 30); + const commentLength = archive.readUInt16LE(offset + 32); + const startingDisk = archive.readUInt16LE(offset + 34); + const localHeaderOffset = archive.readUInt32LE(offset + 42); + const entryEnd = offset + 46 + fileNameLength + extraFieldLength + commentLength; + + if (entryEnd > centralDirectoryEnd) { + throw new Error('a ZIP central-directory entry exceeds its declared bounds'); + } + if (startingDisk !== 0) { + throw new Error('multi-disk ZIP entries are not supported'); + } + if ( + compressedSize === 0xffffffff || + uncompressedSize === 0xffffffff || + localHeaderOffset === 0xffffffff + ) { + throw new Error('ZIP64 entries are not supported'); + } + + const nameBytes = archive.subarray(offset + 46, offset + 46 + fileNameLength); + entries.push({ + name: nameBytes.toString((flags & 0x0800) !== 0 ? 'utf8' : 'latin1'), + nameBytes, + flags, + compressionMethod, + crc, + compressedSize, + uncompressedSize, + localHeaderOffset, + centralDirectoryStart: centralDirectoryOffset, + }); + offset = entryEnd; + } + + if (offset !== centralDirectoryEnd) { + throw new Error('the ZIP central directory size does not match its entries'); + } + + return entries; +} + +function crc32(data) { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function readCompressedEntryData(archive, entry) { + const localHeaderEnd = entry.localHeaderOffset + 30; + if (localHeaderEnd > entry.centralDirectoryStart || localHeaderEnd > archive.length) { + throw new Error(`the local header for '${entry.name}' is truncated`); + } + if (archive.readUInt32LE(entry.localHeaderOffset) !== LOCAL_FILE_HEADER_SIGNATURE) { + throw new Error(`the local header for '${entry.name}' has an invalid signature`); + } + + const localFlags = archive.readUInt16LE(entry.localHeaderOffset + 6); + const localCompressionMethod = archive.readUInt16LE(entry.localHeaderOffset + 8); + const localCrc = archive.readUInt32LE(entry.localHeaderOffset + 14); + const localCompressedSize = archive.readUInt32LE(entry.localHeaderOffset + 18); + const localUncompressedSize = archive.readUInt32LE(entry.localHeaderOffset + 22); + const localFileNameLength = archive.readUInt16LE(entry.localHeaderOffset + 26); + const localExtraFieldLength = archive.readUInt16LE(entry.localHeaderOffset + 28); + const fileNameStart = localHeaderEnd; + const dataStart = fileNameStart + localFileNameLength + localExtraFieldLength; + const dataEnd = dataStart + entry.compressedSize; + + if (dataEnd > entry.centralDirectoryStart || dataEnd > archive.length) { + throw new Error(`the compressed data for '${entry.name}' is truncated`); + } + if (localFlags !== entry.flags || localCompressionMethod !== entry.compressionMethod) { + throw new Error(`the local header for '${entry.name}' disagrees with the central directory`); + } + if ( + (entry.flags & 0x0008) === 0 && + ( + localCrc !== entry.crc || + localCompressedSize !== entry.compressedSize || + localUncompressedSize !== entry.uncompressedSize + ) + ) { + throw new Error(`the local header sizes or checksum for '${entry.name}' disagree with the central directory`); + } + if (!archive.subarray(fileNameStart, fileNameStart + localFileNameLength).equals(entry.nameBytes)) { + throw new Error(`the local header name for '${entry.name}' disagrees with the central directory`); + } + + return archive.subarray(dataStart, dataEnd); +} + +function readEntryData(archive, entry) { + const compressedData = readCompressedEntryData(archive, entry); + if ((entry.flags & 0x0001) !== 0) { + throw new Error(`the required entry '${entry.name}' is encrypted`); + } + if (entry.uncompressedSize > MAX_SOLUTION_XML_SIZE) { + throw new Error(`the required entry '${entry.name}' is unexpectedly large`); + } + + let data; + if (entry.compressionMethod === 0) { + data = compressedData; + } else if (entry.compressionMethod === 8) { + data = zlib.inflateRawSync(compressedData, { + maxOutputLength: Math.max(1, entry.uncompressedSize + 1), + }); + } else { + throw new Error(`the required entry '${entry.name}' uses unsupported compression method ${entry.compressionMethod}`); + } + + if (data.length !== entry.uncompressedSize || crc32(data) !== entry.crc) { + throw new Error(`the required entry '${entry.name}' failed its integrity check`); + } + + return data; +} + +function validateZipContainsSolutionXml(zipPath) { + const archive = fs.readFileSync(zipPath); + const entries = readZipEntries(archive); + + // Checking every local header catches truncated payloads and mismatched metadata + // without expanding arbitrary entries. Only Solution.xml is decompressed because + // it is the required manifest and its CRC must prove the entry itself is intact. + for (const entry of entries) { + readCompressedEntryData(archive, entry); + } + + const solutionEntries = entries.filter((entry) => { + return entry.name.replace(/\\/g, '/').toLowerCase() === 'solution.xml'; + }); + + if (solutionEntries.length === 0) { + return false; + } + if (solutionEntries.length > 1) { + throw new Error('the ZIP contains duplicate Solution.xml entries'); + } + + readEntryData(archive, solutionEntries[0]); + return true; +} runValidation(async (cwd) => { if (readDeferralMarker(findProjectRoot(cwd) || cwd)) return approve(); // ALM deferred — silent-approve. @@ -37,25 +263,26 @@ runValidation(async (cwd) => { // Validate each zip found for (const zipPath of zipFiles) { - const stat = fs.statSync(zipPath); + let stat; + try { + stat = fs.statSync(zipPath); + } catch (error) { + return block(`Solution zip '${path.basename(zipPath)}' could not be read: ${error.message}`); + } if (stat.size < 1000) { return block(`Solution zip '${path.basename(zipPath)}' is too small (${stat.size} bytes). The export may have been truncated or failed.`); } + if (stat.size > MAX_SOLUTION_ZIP_SIZE) { + return block(`Solution zip '${path.basename(zipPath)}' exceeds the supported 100 MiB package size.`); + } - // Verify Solution.xml is inside the zip try { - const output = execSync(`unzip -l "${zipPath}" 2>/dev/null | grep -i solution.xml`, { - encoding: 'utf8', - timeout: 10000, - }); - if (!output || !output.toLowerCase().includes('solution.xml')) { + if (!validateZipContainsSolutionXml(zipPath)) { return block(`Solution zip '${path.basename(zipPath)}' does not contain solution.xml. The export appears corrupt.`); } - } catch { - // unzip not available or grep returned no match - // Fall back to just checking file size — already done above - // Don't block if unzip is unavailable + } catch (error) { + return block(`Solution zip '${path.basename(zipPath)}' could not be validated: ${error.message}`); } }