Skip to content

Commit 142720c

Browse files
Dhiresh Chawlaclaude
andcommitted
Validate --envUrl before passing to shell-interpolating getAuthToken
The CLI took --envUrl from the user and passed it straight to helpers.getAuthToken, which interpolates the URL into `az account get-access-token --resource "${url}"` via execSync. A malicious --envUrl like 'x"; rm -rf ~; echo "' would break out of the shell quotes and execute arbitrary commands. New sanitizeEnvUrl() helper in lib/check-solution-installed.js parses the URL via new URL(), requires https, rejects userinfo, and returns url.origin only (scheme + host + port). URL.origin contains no shell- special characters by the URL spec, so the result is safe to interpolate. Applied to both the --envUrl CLI flag and the URL returned by getEnvironmentUrl() for defense in depth. Adds 8 tests: happy path, strips path/query/fragment/trailing slash, preserves explicit port, neutralizes shell-injection payloads in path/ query, normalizes WHATWG-stripped newlines/tabs, rejects non-https protocols, rejects userinfo, rejects garbage input. This fixes the symptom at the call site. The underlying getAuthToken helper still uses execSync with string interpolation — hardening it (switch to execFile with an args array, etc.) affects 30+ callers and is out of scope for this PR. Addresses Copilot review comment N2 on PR #173. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 27d280d commit 142720c

3 files changed

Lines changed: 157 additions & 5 deletions

File tree

plugins/power-pages/scripts/check-solution-installed.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
// tested without spawning a subprocess or making real network calls.
1919

2020
const { getAuthToken, getEnvironmentUrl } = require('./lib/validation-helpers');
21-
const { checkSolutionInstalled } = require('./lib/check-solution-installed');
21+
const { checkSolutionInstalled, sanitizeEnvUrl } = require('./lib/check-solution-installed');
2222

2323
function parseArgs(argv) {
2424
const args = {};
@@ -37,12 +37,24 @@ async function main() {
3737
process.exit(1);
3838
}
3939

40-
const envUrl = args.envUrl || getEnvironmentUrl();
41-
if (!envUrl) {
40+
const rawEnvUrl = args.envUrl || getEnvironmentUrl();
41+
if (!rawEnvUrl) {
4242
process.stderr.write('No environment URL provided and `pac env who` did not return one. Run `pac auth create` and `pac env select` first.\n');
4343
process.exit(1);
4444
}
4545

46+
// Sanitize before passing anywhere that interpolates the URL into a shell
47+
// command (getAuthToken builds `az account get-access-token --resource
48+
// "${envUrl}"`). sanitizeEnvUrl strips everything except scheme+host+port,
49+
// so a `--envUrl 'x"; rm -rf ~; echo "'` can't escape the quotes.
50+
let envUrl;
51+
try {
52+
envUrl = sanitizeEnvUrl(rawEnvUrl);
53+
} catch (err) {
54+
process.stderr.write(`${err.message}\n`);
55+
process.exit(1);
56+
}
57+
4658
const token = getAuthToken(envUrl);
4759
if (!token) {
4860
process.stderr.write('Failed to get Azure CLI token. Run `az login` first.\n');

plugins/power-pages/scripts/lib/check-solution-installed.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,54 @@ const helpers = require('./validation-helpers');
1111

1212
const UNIQUE_NAME_RE = /^[A-Za-z0-9_]+$/;
1313

14+
/**
15+
* Validates and normalizes a Dataverse environment URL before it is passed
16+
* to anything that interpolates it into a shell command (notably
17+
* helpers.getAuthToken, which calls `az account get-access-token --resource
18+
* "${url}"` via execSync). Returns the URL's `origin` only (scheme + host +
19+
* optional port) so path, query, fragment, and userinfo are all stripped.
20+
*
21+
* Throws on:
22+
* - non-string / empty input
23+
* - input that `new URL()` can't parse
24+
* - non-https protocol (Dataverse refuses http and we don't want file: etc.)
25+
* - URLs with embedded userinfo (https://user:pass@host) — credentials in
26+
* URLs are a smell and can confuse downstream tooling
27+
*
28+
* The normalized origin is safe to interpolate into a shell command because
29+
* URL.origin only contains scheme, host, and port — characters that the
30+
* URL spec disallows from carrying shell metacharacters.
31+
*
32+
* @param {unknown} envUrl
33+
* @returns {string} sanitized origin, e.g. "https://contoso.crm.dynamics.com"
34+
* @throws Error with a human-readable message on rejection
35+
*/
36+
function sanitizeEnvUrl(envUrl) {
37+
if (typeof envUrl !== 'string' || envUrl.trim() === '') {
38+
throw new Error('envUrl must be a non-empty string.');
39+
}
40+
41+
let parsed;
42+
try {
43+
parsed = new URL(envUrl);
44+
} catch {
45+
throw new Error(`envUrl is not a valid URL: "${envUrl}".`);
46+
}
47+
48+
if (parsed.protocol !== 'https:') {
49+
throw new Error(`envUrl must use https (got "${parsed.protocol}").`);
50+
}
51+
52+
if (parsed.username || parsed.password) {
53+
throw new Error('envUrl must not contain userinfo (username/password). Authentication uses the Azure CLI token, not credentials in the URL.');
54+
}
55+
56+
// url.origin is the scheme + host + port — no path, no query, no fragment.
57+
// For "https://contoso.crm.dynamics.com:443/api/data/v9.2/?x=1#anchor"
58+
// it returns "https://contoso.crm.dynamics.com:443".
59+
return parsed.origin;
60+
}
61+
1462
/**
1563
* @typedef {Object} CheckResult
1664
* @property {boolean} installed
@@ -80,4 +128,4 @@ async function checkSolutionInstalled({ envUrl, token, solutionName } = {}) {
80128
return { installed: false, solutionName };
81129
}
82130

83-
module.exports = { checkSolutionInstalled, UNIQUE_NAME_RE };
131+
module.exports = { checkSolutionInstalled, sanitizeEnvUrl, UNIQUE_NAME_RE };

plugins/power-pages/scripts/tests/check-solution-installed.test.js

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const test = require('node:test');
44
const assert = require('node:assert/strict');
55

66
const helpers = require('../lib/validation-helpers');
7-
const { checkSolutionInstalled } = require('../lib/check-solution-installed');
7+
const { checkSolutionInstalled, sanitizeEnvUrl } = require('../lib/check-solution-installed');
88

99
const ENV_URL = 'https://contoso.crm.dynamics.com';
1010
const TOKEN = 'fake-token';
@@ -139,3 +139,95 @@ test('throws when the response body is not valid JSON', async (t) => {
139139
/Failed to parse Dataverse response as JSON/
140140
);
141141
});
142+
143+
// --- sanitizeEnvUrl: defense against command injection via --envUrl ---
144+
//
145+
// The output of sanitizeEnvUrl is passed to helpers.getAuthToken, which
146+
// interpolates it into `az account get-access-token --resource "${url}"`
147+
// via execSync (a shell command). If we didn't sanitize, an attacker who
148+
// could pass a malicious --envUrl on the CLI could execute arbitrary
149+
// shell commands.
150+
151+
test('sanitizeEnvUrl accepts a plain Dataverse URL and returns just the origin', () => {
152+
assert.equal(
153+
sanitizeEnvUrl('https://contoso.crm.dynamics.com'),
154+
'https://contoso.crm.dynamics.com'
155+
);
156+
});
157+
158+
test('sanitizeEnvUrl strips path, query, and fragment from the URL', () => {
159+
assert.equal(
160+
sanitizeEnvUrl('https://contoso.crm.dynamics.com/api/data/v9.2/solutions?$top=1#hash'),
161+
'https://contoso.crm.dynamics.com'
162+
);
163+
});
164+
165+
test('sanitizeEnvUrl preserves an explicit port', () => {
166+
assert.equal(
167+
sanitizeEnvUrl('https://contoso.crm.dynamics.com:8443/some/path'),
168+
'https://contoso.crm.dynamics.com:8443'
169+
);
170+
});
171+
172+
test('sanitizeEnvUrl strips a trailing slash by normalizing to origin', () => {
173+
assert.equal(
174+
sanitizeEnvUrl('https://contoso.crm.dynamics.com/'),
175+
'https://contoso.crm.dynamics.com'
176+
);
177+
});
178+
179+
test('sanitizeEnvUrl rejects shell-injection payloads embedded in the URL', () => {
180+
// The whole point of using URL.origin is that these characters are stripped
181+
// (in path/query/fragment) or rejected by URL parsing (in host).
182+
// Verify a few representative payloads no longer make it through.
183+
184+
// Path-position payload: URL parses fine, but origin throws away the path.
185+
assert.equal(
186+
sanitizeEnvUrl('https://contoso.crm.dynamics.com/"; rm -rf ~; echo "'),
187+
'https://contoso.crm.dynamics.com'
188+
);
189+
190+
// Query-position payload: same story.
191+
assert.equal(
192+
sanitizeEnvUrl('https://contoso.crm.dynamics.com?x="; rm -rf ~; echo "'),
193+
'https://contoso.crm.dynamics.com'
194+
);
195+
196+
// Newline in the URL — WHATWG URL parsing strips ASCII tabs and newlines
197+
// per spec, so a newline-laced URL gets normalized to a safe origin rather
198+
// than carrying the newline downstream. This is the behavior we want — a
199+
// newline in a shell command argument can be used to break out of a quoted
200+
// string.
201+
assert.equal(
202+
sanitizeEnvUrl('https://contoso\ndynamics.com'),
203+
'https://contosodynamics.com'
204+
);
205+
assert.doesNotMatch(sanitizeEnvUrl('https://contoso\tdynamics.com'), /\s/);
206+
});
207+
208+
test('sanitizeEnvUrl rejects non-https protocols', () => {
209+
assert.throws(() => sanitizeEnvUrl('http://contoso.crm.dynamics.com'), /must use https/);
210+
assert.throws(() => sanitizeEnvUrl('file:///etc/passwd'), /must use https/);
211+
assert.throws(() => sanitizeEnvUrl('javascript:alert(1)'), /must use https/);
212+
});
213+
214+
test('sanitizeEnvUrl rejects URLs containing userinfo (credentials)', () => {
215+
assert.throws(
216+
() => sanitizeEnvUrl('https://attacker:pwn@contoso.crm.dynamics.com'),
217+
/must not contain userinfo/
218+
);
219+
assert.throws(
220+
() => sanitizeEnvUrl('https://attacker@contoso.crm.dynamics.com'),
221+
/must not contain userinfo/
222+
);
223+
});
224+
225+
test('sanitizeEnvUrl rejects garbage input', () => {
226+
assert.throws(() => sanitizeEnvUrl(''), /non-empty string/);
227+
assert.throws(() => sanitizeEnvUrl(' '), /non-empty string/);
228+
assert.throws(() => sanitizeEnvUrl(null), /non-empty string/);
229+
assert.throws(() => sanitizeEnvUrl(undefined), /non-empty string/);
230+
assert.throws(() => sanitizeEnvUrl(42), /non-empty string/);
231+
assert.throws(() => sanitizeEnvUrl('not a url'), /not a valid URL/);
232+
assert.throws(() => sanitizeEnvUrl('://broken'), /not a valid URL/);
233+
});

0 commit comments

Comments
 (0)