Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6434f49
docs: secure Power Pages approvals
priyanshu92 Aug 6, 2026
3abedfb
Pin Power Pages Playwright MCP
priyanshu92 Aug 6, 2026
cef3b4f
Harden Playwright MCP root resolution
priyanshu92 Aug 6, 2026
e1b328b
Merge remote-tracking branch 'refs/remotes/origin/users/priyanshu92/r…
priyanshu92 Aug 6, 2026
fd3ae61
Harden export solution ZIP validation
priyanshu92 Aug 6, 2026
5e58408
Harden Power Pages command and URL handling
priyanshu92 Aug 6, 2026
da858e2
Merge PR 382 root hardening
priyanshu92 Aug 6, 2026
6136d27
address PR review feedback
priyanshu92 Aug 6, 2026
928bd48
address PR review feedback
priyanshu92 Aug 6, 2026
9294cd7
address PR review feedback
priyanshu92 Aug 6, 2026
566c2d0
Accept case-insensitive HTTPS schemes
priyanshu92 Aug 6, 2026
0a0e8e7
Close remaining Power Pages shell sinks
priyanshu92 Aug 6, 2026
83e730f
Make shell regression harness portable
priyanshu92 Aug 6, 2026
c09f2a0
Canonicalize detected host URLs
priyanshu92 Aug 6, 2026
34660d1
Merge PR #385 ZIP validation
priyanshu92 Aug 6, 2026
95c07be
address PR review feedback
priyanshu92 Aug 6, 2026
a8a13d4
Trigger stacked PR checks
priyanshu92 Aug 6, 2026
1f08be1
Run stacked PR checks
priyanshu92 Aug 6, 2026
af93501
Align integration request transport
priyanshu92 Aug 6, 2026
b8bac6b
Run final stacked PR checks
priyanshu92 Aug 6, 2026
25bb930
Separate integration header contract tests
priyanshu92 Aug 6, 2026
953c055
Merge PR 383 into safe ZIP validation
priyanshu92 Aug 6, 2026
04954fe
Merge updated safe ZIP validation layer
priyanshu92 Aug 6, 2026
2ddd2c9
address PR review feedback
priyanshu92 Aug 11, 2026
0bb7b7d
Merge PR 382 root hardening updates
priyanshu92 Aug 11, 2026
437333e
address PR review feedback
priyanshu92 Aug 11, 2026
fa5f35c
Merge final PR 382 containment fix
priyanshu92 Aug 11, 2026
04ca651
Merge final PR 383 tip into safe ZIP validation
priyanshu92 Aug 11, 2026
48afdd2
Merge final safe ZIP validation layer
priyanshu92 Aug 11, 2026
adc3c22
Merge commit '362eb38dfea65fef4dfa026f8cacb19ad77fe3af' into users/pr…
priyanshu92 Aug 11, 2026
614e962
Merge commit 'adc3c2294ee05dcdedbe439ee52be305ae1269ab' into users/pr…
priyanshu92 Aug 11, 2026
fd5fd58
Merge updated PR 382 base
priyanshu92 Aug 11, 2026
156559b
Merge latest PR 383 tip into safe ZIP validation
priyanshu92 Aug 11, 2026
27c9c61
Merge latest safe ZIP validation layer
priyanshu92 Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 5 additions & 41 deletions plugins/power-pages/scripts/lib/check-solution-installed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
29 changes: 14 additions & 15 deletions plugins/power-pages/scripts/lib/ensure-pipelines-host-detect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -86,23 +84,21 @@ function parseArgs(argv) {

function originOf(url) {
try {
const u = new URL(url);
const trustedUrl = helpers.validateAuthenticatedRequestUrl(url);
const u = new URL(trustedUrl);
helpers.validateDataverseEnvironmentUrl(u.origin);
return `${u.protocol}//${u.host}`;
} 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 }) {
Expand Down Expand Up @@ -173,12 +169,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,
Expand Down Expand Up @@ -212,7 +209,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
Expand All @@ -228,14 +225,16 @@ async function detect(opts = {}) {
}

baseOut.finalHostEnvId = env.envId;
helpers.validateDataverseEnvironmentUrl(env.instanceUrl, 'Resolved host environment URL');
helpers.validateDataverseEnvironmentUrl(env.instanceApiUrl, 'Resolved host API URL');
baseOut.finalHostEnvUrl = env.instanceUrl;
baseOut.finalHostEnvName = env.displayName || null;
baseOut.finalHostInstanceApiUrl = env.instanceApiUrl;
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.
Expand Down
10 changes: 8 additions & 2 deletions plugins/power-pages/scripts/lib/estimate-solution-size.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
32 changes: 21 additions & 11 deletions plugins/power-pages/scripts/lib/fix-blocked-attachments.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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}`);
}
Expand All @@ -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,
Expand All @@ -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}`);
}
Expand All @@ -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,
Expand Down
30 changes: 21 additions & 9 deletions plugins/power-pages/scripts/lib/install-pipelines-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand All @@ -218,10 +219,14 @@ 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.
Expand All @@ -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',
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -400,7 +407,11 @@ 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.validateBapUrl(rawLocationHeader) : null;
if (locationHeader && new URL(locationHeader).origin !== new URL(cleanBase).origin) {
throw new Error('BAP applicationPackages install returned a Location header for a different host.');
Comment thread
priyanshu92 marked this conversation as resolved.
Outdated
}
let retryAfterSec = readRetryAfterSec(postRes.headers) || DEFAULT_RETRY_AFTER_SEC;

if (postRes.statusCode === 200 && isTerminalSucceeded(provisioningState)) {
Expand Down Expand Up @@ -509,6 +520,7 @@ module.exports = {
isTerminalSucceeded,
isTerminalFailed,
readRetryAfterSec,
tryPacFallback,
PIPELINES_PACKAGE_UNIQUE_NAMES,
PIPELINES_PACKAGE_DISPLAY_PATTERNS,
PIPELINES_SOLUTION_UNIQUE_NAME,
Expand Down
22 changes: 10 additions & 12 deletions plugins/power-pages/scripts/lib/list-tenant-envs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading