diff --git a/package.json b/package.json index e2f110ee3..51611f7b2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "import": "./dist/src/remote-config.js", "types": "./dist/src/remote-config.d.ts" }, + "./install-source": { + "import": "./dist/src/install-source.js", + "types": "./dist/src/install-source.d.ts" + }, "./contracts": { "import": "./dist/src/contracts.js", "types": "./dist/src/contracts.d.ts" diff --git a/rslib.config.ts b/rslib.config.ts index 5d26d9c35..9fe004be3 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ source: { entry: { index: 'src/index.ts', + 'install-source': 'src/install-source.ts', metro: 'src/metro.ts', 'remote-config': 'src/remote-config.ts', contracts: 'src/contracts.ts', diff --git a/src/install-source.ts b/src/install-source.ts new file mode 100644 index 000000000..b464b4117 --- /dev/null +++ b/src/install-source.ts @@ -0,0 +1,13 @@ +export { + ARCHIVE_EXTENSIONS, + isBlockedIpAddress, + isBlockedSourceHostname, + isTrustedInstallSourceUrl, + materializeInstallablePath, + validateDownloadSourceUrl, +} from './platforms/install-source.ts'; + +export type { + MaterializeInstallSource, + MaterializedInstallable, +} from './platforms/install-source.ts'; diff --git a/src/platforms/__tests__/install-source.test.ts b/src/platforms/__tests__/install-source.test.ts index 7460a12e8..aa61662ca 100644 --- a/src/platforms/__tests__/install-source.test.ts +++ b/src/platforms/__tests__/install-source.test.ts @@ -6,10 +6,13 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { + ARCHIVE_EXTENSIONS, + isBlockedIpAddress, + isBlockedSourceHostname, isTrustedInstallSourceUrl, materializeInstallablePath, validateDownloadSourceUrl, -} from '../install-source.ts'; +} from '../../install-source.ts'; import { prepareAndroidInstallArtifact } from '../android/install-artifact.ts'; import { prepareIosInstallArtifact } from '../ios/install-artifact.ts'; @@ -46,6 +49,15 @@ test('validateDownloadSourceUrl rejects unsupported protocols', async () => { ); }); +test('public install-source helpers expose the SSRF and archive surface', () => { + assert.deepEqual(ARCHIVE_EXTENSIONS, ['.zip', '.tar', '.tar.gz', '.tgz']); + assert.equal(Object.isFrozen(ARCHIVE_EXTENSIONS), true); + assert.equal(isBlockedSourceHostname('localhost'), true); + assert.equal(isBlockedSourceHostname('example.com'), false); + assert.equal(isBlockedIpAddress('127.0.0.1'), true); + assert.equal(isBlockedIpAddress('203.0.113.10'), false); +}); + test('isTrustedInstallSourceUrl recognizes supported artifact services', () => { assert.equal( isTrustedInstallSourceUrl('https://api.github.com/repos/acme/app/actions/artifacts/1/zip'), diff --git a/src/platforms/install-source.ts b/src/platforms/install-source.ts index 5ed5e2db2..dc2b1c0ec 100644 --- a/src/platforms/install-source.ts +++ b/src/platforms/install-source.ts @@ -42,7 +42,9 @@ export type MaterializedInstallable = { cleanup: () => Promise; }; -const ARCHIVE_EXTENSIONS = ['.zip', '.tar', '.tar.gz', '.tgz'] as const; +const INTERNAL_ARCHIVE_EXTENSIONS = ['.zip', '.tar', '.tar.gz', '.tgz'] as const; + +export const ARCHIVE_EXTENSIONS = Object.freeze([...INTERNAL_ARCHIVE_EXTENSIONS] as const); const MAX_INSTALL_SOURCE_SEARCH_DEPTH = 5; const DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS = resolveTimeoutMs( process.env.AGENT_DEVICE_SOURCE_DOWNLOAD_TIMEOUT_MS, @@ -271,13 +273,13 @@ function resolveDownloadFileName(response: Response, parsedUrl: URL): string { return 'downloaded-artifact.bin'; } -function isBlockedSourceHostname(hostname: string): boolean { +export function isBlockedSourceHostname(hostname: string): boolean { if (!hostname) return true; if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true; return isBlockedIpAddress(hostname); } -function isBlockedIpAddress(address: string): boolean { +export function isBlockedIpAddress(address: string): boolean { const family = net.isIP(address); if (family === 4) return isBlockedIpv4(address); if (family === 6) return isBlockedIpv6(address); @@ -449,7 +451,7 @@ async function extractArchive( function isArchivePath(candidatePath: string): boolean { const lower = candidatePath.toLowerCase(); - return ARCHIVE_EXTENSIONS.some((extension) => lower.endsWith(extension)); + return INTERNAL_ARCHIVE_EXTENSIONS.some((extension) => lower.endsWith(extension)); } async function runCleanupTasks(tasks: Array<() => Promise>): Promise {