|
1 | 1 | import type { AddressInfo } from 'node:net' |
2 | 2 | import type { MockInstance } from 'vitest' |
3 | | -import type { RemoteAssets, RemoteAssetsErrorMessage, RemoteAssetsStore } from '../types/remote-assets' |
| 3 | +import type { RemoteAssets, RemoteAssetsErrorMessage, RemoteAssetsProviderCustom, RemoteAssetsStore } from '../types/remote-assets' |
4 | 4 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' |
5 | 5 | import { createServer } from 'node:http' |
6 | 6 | import { tmpdir } from 'node:os' |
7 | | -import { join } from 'node:path' |
| 7 | +import { dirname, join } from 'node:path' |
8 | 8 | import { pathToFileURL } from 'node:url' |
9 | 9 | import { H3, toNodeHandler } from 'h3' |
10 | 10 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
@@ -188,6 +188,43 @@ describe('resolveStaticAssetsSource (remote store)', () => { |
188 | 188 | expect(existsSync(join(target, 'package.json'))).toBe(false) |
189 | 189 | }) |
190 | 190 |
|
| 191 | + it('rejects unsafe provider-listed paths before fetching or writing them', async () => { |
| 192 | + const calls: string[] = [] |
| 193 | + const fetchImpl: typeof globalThis.fetch = async (input) => { |
| 194 | + const url = String(input) |
| 195 | + calls.push(url) |
| 196 | + return url.endsWith('/dist/assets/app.js') ? new Response('console.log("app")') : new Response('should never be served') |
| 197 | + } |
| 198 | + const provider: RemoteAssetsProviderCustom = { |
| 199 | + fileUrl: (pkg, version, filePath) => `https://mirror.example.com/${pkg}@${version}/${filePath}`, |
| 200 | + // A compromised (or merely buggy) custom provider — every entry below is |
| 201 | + // unsafe or out of scope except the one normal nested asset. |
| 202 | + listFiles: async () => [ |
| 203 | + 'package.json', // ordinary file outside the selected prefix — stays ignored |
| 204 | + 'dist/assets/app.js', // a normal nested asset — still materializes |
| 205 | + 'dist/../evil-traversal.txt', // prefixed traversal entry |
| 206 | + '/outside/evil-absolute.txt', // absolute path entry |
| 207 | + 'dist/evil\\..\\..\\evil-backslash.txt', // backslash traversal entry — rejected on every platform |
| 208 | + 'dist-confusable/evil-prefix.txt', // prefix-confusion entry — outside the selected prefix |
| 209 | + ], |
| 210 | + } |
| 211 | + const store = storeFor({ fetch: fetchImpl }, makeTmp(), { provider }) |
| 212 | + const target = makeTmp() |
| 213 | + |
| 214 | + await store.materialize(target) |
| 215 | + |
| 216 | + // The one normal nested asset still materializes. |
| 217 | + expect(readFileSync(join(target, 'assets/app.js'), 'utf8')).toBe('console.log("app")') |
| 218 | + // Nothing else was fetched... |
| 219 | + expect(calls).toEqual([expect.stringContaining('/dist/assets/app.js')]) |
| 220 | + // ...or written, inside or outside the target directory. |
| 221 | + expect(existsSync(join(target, 'package.json'))).toBe(false) |
| 222 | + expect(existsSync(join(target, 'evil-traversal.txt'))).toBe(false) |
| 223 | + expect(existsSync(join(dirname(target), 'evil-traversal.txt'))).toBe(false) |
| 224 | + expect(existsSync(join(target, 'evil-backslash.txt'))).toBe(false) |
| 225 | + expect(existsSync(join(dirname(target), 'evil-prefix.txt'))).toBe(false) |
| 226 | + }) |
| 227 | + |
191 | 228 | it('supports the unpkg provider URL scheme', async () => { |
192 | 229 | const calls: string[] = [] |
193 | 230 | const fetchImpl: typeof globalThis.fetch = async (input) => { |
|
0 commit comments