Skip to content

Commit 316dd7b

Browse files
authored
fix(create): reject non-semver versions in org-template manifests (#2665)
Malformed registry versions can place org-template files outside the cache root. `readOrgManifest()` now rejects versions that fail `semver.valid()`, including targets from `dist-tags.latest` and tags that match a requested version. `resolveExtractionDir()` also checks that the extraction path stays within the cache root. Tests cover invalid versions, path traversal, and valid versions with prerelease or build metadata.
1 parent 93c15c9 commit 316dd7b

4 files changed

Lines changed: 112 additions & 5 deletions

File tree

packages/cli/src/create/__tests__/org-manifest.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,4 +509,30 @@ describe('readOrgManifest', () => {
509509
/version "9\.9\.9" not found/,
510510
);
511511
});
512+
513+
it.each([
514+
['latest', '../../outside-write'],
515+
['latest', '..'],
516+
['latest', '.'],
517+
['latest', 'not-a-version'],
518+
// A tag named like a pinned version can also substitute an invalid target.
519+
['1.2.3', '../../outside-write'],
520+
])('rejects a non-semver dist-tags.%s target: %s', async (tag, version) => {
521+
const body = packument([
522+
{ name: 'web', description: 'v1', template: '@your-org/template-web' },
523+
]);
524+
// Keep matching metadata so only the version validation rejects it.
525+
mockFetchJson({
526+
...body,
527+
'dist-tags': { ...body['dist-tags'], [tag]: version },
528+
versions: {
529+
...body.versions,
530+
[version]: { ...body.versions['1.0.0'], version },
531+
},
532+
});
533+
const requestedVersion = tag === 'latest' ? undefined : tag;
534+
const manifest = readOrgManifest('@your-org', requestedVersion);
535+
await expect(manifest).rejects.toThrow(OrgManifestSchemaError);
536+
await expect(manifest).rejects.toThrow(/invalid version/);
537+
});
512538
});

packages/cli/src/create/__tests__/org-tarball.spec.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@ import fs from 'node:fs';
22
import os from 'node:os';
33
import path from 'node:path';
44

5-
import { afterEach, describe, expect, it } from 'vitest';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
66

7+
import type { OrgManifest } from '../org-manifest.js';
78
import {
89
cleanupStaleStagingDirs,
10+
ensureOrgPackageExtracted,
911
normalizeEntryName,
1012
parseEntryMode,
1113
resolveBundledPath,
14+
resolveExtractionDir,
1215
sanitizeHostForPath,
1316
} from '../org-tarball.js';
1417

18+
const { mockGetVpDirs } = vi.hoisted(() => ({ mockGetVpDirs: vi.fn() }));
19+
20+
vi.mock('../../../binding/index.js', () => ({ getVpDirs: mockGetVpDirs }));
21+
1522
describe('resolveBundledPath', () => {
1623
const scratchDirs: string[] = [];
1724

@@ -88,6 +95,62 @@ describe('normalizeEntryName', () => {
8895
});
8996
});
9097

98+
function manifestFor(version: string): OrgManifest {
99+
return {
100+
scope: '@your-org',
101+
packageName: '@your-org/create',
102+
version,
103+
tarballUrl: 'https://registry.npmjs.org/@your-org/create/-/create-1.0.0.tgz',
104+
templates: [],
105+
};
106+
}
107+
108+
describe('resolveExtractionDir', () => {
109+
const cacheRoot = path.resolve(os.tmpdir(), 'vp-cache-root');
110+
111+
it.each(['1.0.0', '0.0.0', '1.2.3', '2.0.0-beta.1', '10.20.30+build.5'])(
112+
'places version %s beneath the cache root',
113+
(version) => {
114+
expect(resolveExtractionDir(cacheRoot, manifestFor(version))).toBe(
115+
path.join(cacheRoot, 'registry.npmjs.org', '@your-org', 'create', version),
116+
);
117+
},
118+
);
119+
120+
// Three `..` segments reach the cache root; four escape it.
121+
it.each(['../../..', '../../../../outside', '../../../../../../outside-write', '/absolute'])(
122+
'rejects a version that resolves to or outside the cache root: %s',
123+
(version) => {
124+
expect(() => resolveExtractionDir(cacheRoot, manifestFor(version))).toThrow(
125+
/escapes the cache root/,
126+
);
127+
},
128+
);
129+
});
130+
131+
describe('ensureOrgPackageExtracted', () => {
132+
afterEach(() => {
133+
vi.restoreAllMocks();
134+
});
135+
136+
it('rejects the cache root before filesystem or network activity', async () => {
137+
mockGetVpDirs.mockReturnValue({ cache: path.join(os.tmpdir(), 'vp-org-extraction-cache') });
138+
const exists = vi.spyOn(fs, 'existsSync').mockReturnValue(false);
139+
const mkdir = vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
140+
const readdir = vi.spyOn(fs.promises, 'readdir').mockResolvedValue([]);
141+
const fetch = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('unexpected download'));
142+
143+
await expect(ensureOrgPackageExtracted(manifestFor('../../..'))).rejects.toThrow(
144+
/escapes the cache root/,
145+
);
146+
147+
expect(exists).not.toHaveBeenCalled();
148+
expect(mkdir).not.toHaveBeenCalled();
149+
expect(readdir).not.toHaveBeenCalled();
150+
expect(fetch).not.toHaveBeenCalled();
151+
});
152+
});
153+
91154
describe('sanitizeHostForPath', () => {
92155
it('passes through plain hostnames untouched', () => {
93156
expect(sanitizeHostForPath('registry.npmjs.org')).toBe('registry.npmjs.org');

packages/cli/src/create/org-manifest.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import path from 'node:path';
22

3+
import semver from 'semver';
4+
35
import { fetchNpmResource, getNpmRegistry } from '../utils/npm-config.ts';
46
import { readPackageJsonFromTarball } from './org-tarball.ts';
57

@@ -333,6 +335,14 @@ export async function readOrgManifest(
333335
return null;
334336
}
335337
}
338+
// Registry versions become cache-path components, so reject malformed
339+
// values even when the registry has matching version metadata.
340+
if (semver.valid(resolvedVersion) === null) {
341+
throw new OrgManifestSchemaError(
342+
`invalid version "${resolvedVersion}" (expected a semantic version)`,
343+
packageName,
344+
);
345+
}
336346
const meta = packument.versions?.[resolvedVersion];
337347
if (!meta) {
338348
return null;

packages/cli/src/create/org-tarball.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,24 @@ export function sanitizeHostForPath(host: string): string {
2828
* (via `.npmrc` scope mappings) don't share a cache slot. The registry
2929
* guarantees `manifest.tarballUrl` is a valid URL, so any parse failure
3030
* here is a real bug worth surfacing.
31+
*
32+
* Require a strict descendant of `cacheRoot` so sibling staging directories
33+
* stay inside the cache, even if a caller skips `readOrgManifest` validation.
3134
*/
32-
function getExtractionDir(manifest: OrgManifest): string {
35+
export function resolveExtractionDir(cacheRoot: string, manifest: OrgManifest): string {
3336
const { host } = new URL(manifest.tarballUrl);
34-
return path.join(
35-
getCacheRoot(),
37+
const resolvedRoot = path.resolve(cacheRoot);
38+
const resolvedDir = path.resolve(
39+
resolvedRoot,
3640
sanitizeHostForPath(host),
3741
manifest.scope,
3842
'create',
3943
manifest.version,
4044
);
45+
if (!resolvedDir.startsWith(`${resolvedRoot}${path.sep}`)) {
46+
throw new Error(`org template extraction path escapes the cache root: ${manifest.version}`);
47+
}
48+
return resolvedDir;
4149
}
4250

4351
function parseIntegrity(integrity: string): { algorithm: string; expected: string } | null {
@@ -295,7 +303,7 @@ export async function cleanupStaleStagingDirs(destDir: string): Promise<void> {
295303
* cleans up and returns the existing directory.
296304
*/
297305
export async function ensureOrgPackageExtracted(manifest: OrgManifest): Promise<string> {
298-
const extractedRoot = getExtractionDir(manifest);
306+
const extractedRoot = resolveExtractionDir(getCacheRoot(), manifest);
299307
if (fs.existsSync(path.join(extractedRoot, 'package.json'))) {
300308
return extractedRoot;
301309
}

0 commit comments

Comments
 (0)