Skip to content

Commit 01acd22

Browse files
committed
feat(devframe): validate remote-assets package name and version
A remote source's `package` and `version` are interpolated into CDN URLs and the on-disk cache path, so `resolveStaticAssetsSource` now rejects a value that isn't a valid npm package name / exact semver version (new `DF0065`) — closing off malformed URLs and cache-path traversal (e.g. a `..` version segment).
1 parent 9fde241 commit 01acd22

5 files changed

Lines changed: 106 additions & 0 deletions

File tree

docs/errors/DF0065.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0065: Invalid Remote Assets Package Or Version
6+
7+
## Message
8+
9+
> Invalid remote-assets `{field}` "`{value}`".
10+
11+
## Cause
12+
13+
A remote-assets source's `package` and `version` are interpolated into CDN URLs (`https://cdn.jsdelivr.net/npm/<package>@<version>/…`) and into the on-disk cache path (`.remote-assets/<package>@<version>/`). To keep those safe and well-formed, the `package` must be a valid npm package name and the `version` an exact semver version — a value carrying path separators, `@`, whitespace, or traversal segments (`..`) is rejected.
14+
15+
## Example
16+
17+
```ts
18+
defineDevframe({
19+
cli: {
20+
distDir: {
21+
package: '@devframes/plugin-git-client',
22+
version: '../etc', // ✗ not a semver version
23+
},
24+
},
25+
})
26+
```
27+
28+
## Fix
29+
30+
Use a valid npm package name and an exact version:
31+
32+
```ts
33+
defineDevframe({
34+
cli: {
35+
distDir: {
36+
package: '@devframes/plugin-git-client',
37+
version: '1.2.3',
38+
},
39+
},
40+
})
41+
```
42+
43+
## Source
44+
45+
- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts)`resolveStaticAssetsSource()` validates a remote source before resolving it.

packages/devframe/src/node/diagnostics.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,5 +166,10 @@ export const diagnostics = defineDiagnostics({
166166
`Failed to materialize the remote assets of "${p.package}@${p.version}": ${p.reason}`,
167167
fix: 'Static builds need every asset file up front. Install the assets package locally, or ensure the provider (and its file-listing API) is reachable during the build.',
168168
},
169+
DF0065: {
170+
why: (p: { field: 'package' | 'version', value: string }) =>
171+
`Invalid remote-assets ${p.field} "${p.value}".`,
172+
fix: 'A remote-assets `package` must be a valid npm package name and `version` an exact semver version (e.g. `1.2.3`) — they are interpolated into CDN URLs and the cache path.',
173+
},
169174
},
170175
})

packages/devframe/src/utils/remote-assets.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,33 @@ describe('resolveStaticAssetsSource (installed package)', () => {
208208
})
209209
})
210210

211+
describe('resolveStaticAssetsSource (validation)', () => {
212+
it.each([
213+
['UPPERCASE/name', '1.2.3'],
214+
['has spaces', '1.2.3'],
215+
['../escape', '1.2.3'],
216+
['@scope/', '1.2.3'],
217+
])('rejects invalid package %j (DF0065)', (pkg, version) => {
218+
expect(() => resolveStaticAssetsSource({ package: pkg, version }, makeTmp())).toThrow(/Invalid remote-assets package/)
219+
})
220+
221+
it.each([
222+
['@scope/ok', '../etc'],
223+
['@scope/ok', 'latest'],
224+
['@scope/ok', '1.2'],
225+
['@scope/ok', '1.2.3/x'],
226+
])('rejects invalid version for %j (DF0065)', (pkg, version) => {
227+
expect(() => resolveStaticAssetsSource({ package: pkg, version }, makeTmp())).toThrow(/Invalid remote-assets version/)
228+
})
229+
230+
it('accepts valid scoped names and semver (incl. prerelease/build)', () => {
231+
const tmp = makeTmp()
232+
for (const version of ['1.2.3', '0.9.0-beta.4', '1.0.0+build.5', '10.20.30-rc.1+meta']) {
233+
expect(() => resolveStaticAssetsSource({ package: '@devframes/plugin-git-client', version, fetch: async () => new Response(null) }, tmp)).not.toThrow()
234+
}
235+
})
236+
})
237+
211238
describe('serveStaticHandler with a remote store', () => {
212239
async function serve(store: RemoteAssetsStore): Promise<{ url: string, close: () => Promise<void> }> {
213240
const app = new H3()

packages/devframe/src/utils/remote-assets.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,24 @@ function errText(error: unknown): string {
304304
return error instanceof Error ? error.message : String(error)
305305
}
306306

307+
// ---------------------------------------------------------------------------
308+
// Validation
309+
// ---------------------------------------------------------------------------
310+
311+
// npm package name rules (github.com/npm/validate-npm-package-name), and an
312+
// exact semver version — both interpolated into CDN URLs and the cache path,
313+
// so they must not carry separators, `@`, or traversal segments.
314+
const PACKAGE_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/
315+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[a-z0-9-]+(?:\.[a-z0-9-]+)*)?(?:\+[a-z0-9-]+(?:\.[a-z0-9-]+)*)?$/i
316+
317+
/** Reject a {@link RemoteAssets} with an unsafe package name or version (`DF0065`). */
318+
function assertValidRemoteAssets(assets: RemoteAssets): void {
319+
if (assets.package.length > 214 || !PACKAGE_NAME_RE.test(assets.package))
320+
throw diagnostics.DF0065({ field: 'package', value: assets.package })
321+
if (!VERSION_RE.test(assets.version))
322+
throw diagnostics.DF0065({ field: 'version', value: assets.version })
323+
}
324+
307325
// ---------------------------------------------------------------------------
308326
// Source resolution
309327
// ---------------------------------------------------------------------------
@@ -314,13 +332,17 @@ function errText(error: unknown): string {
314332
* locally installed copy of its package when present) or a caching
315333
* {@link RemoteAssetsStore} back-proxy. Remote caches live under
316334
* `<projectStorageDir>/.remote-assets/<package>@<version>/`.
335+
*
336+
* A remote source's `package`/`version` are validated first (`DF0065`) — both
337+
* are interpolated into CDN URLs and the cache path.
317338
*/
318339
export function resolveStaticAssetsSource(
319340
source: StaticAssetsSource,
320341
projectStorageDir: string,
321342
): string | RemoteAssetsStore {
322343
if (typeof source === 'string')
323344
return source
345+
assertValidRemoteAssets(source)
324346
return resolveInstalled(source)
325347
?? createStore(source, join(projectStorageDir, '.remote-assets', `${source.package.replace(/\//g, '+')}@${source.version}`))
326348
}

tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,13 @@ export declare const diagnostics: import("nostics").Diagnostics<{
298298
}) => string;
299299
readonly fix: "Static builds need every asset file up front. Install the assets package locally, or ensure the provider (and its file-listing API) is reachable during the build.";
300300
};
301+
readonly DF0065: {
302+
readonly why: (p: {
303+
field: "package" | "version";
304+
value: string;
305+
}) => string;
306+
readonly fix: "A remote-assets `package` must be a valid npm package name and `version` an exact semver version (e.g. `1.2.3`) — they are interpolated into CDN URLs and the cache path.";
307+
};
301308
}, readonly [typeof devframeReporter]>;
302309
// #endregion
303310

0 commit comments

Comments
 (0)