From 8d5cf7c13fb0191778571023fb182631c76ea655 Mon Sep 17 00:00:00 2001 From: Siegfried Puchbauer Date: Thu, 29 Jan 2026 22:24:00 -0500 Subject: [PATCH] Add dynamic isAllowed function for network policy Allow configuring network access with a custom function that dynamically checks whether a URL/method combination is allowed. The function receives { method, url } and can return boolean or Promise. This provides more flexibility than static allowedUrlPrefixes for cases like hostname pattern matching or async authorization checks. --- README.md | 10 + src/browser.ts | 2 +- src/index.ts | 2 +- src/network/allow-list/isAllowed.test.ts | 294 +++++++++++++++++++++++ src/network/fetch.ts | 47 +++- src/network/index.ts | 1 + src/network/types.ts | 28 +++ 7 files changed, 375 insertions(+), 9 deletions(-) create mode 100644 src/network/allow-list/isAllowed.test.ts diff --git a/README.md b/README.md index 7825eae5..c1945116 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,16 @@ const env = new Bash({ const env = new Bash({ network: { dangerouslyAllowFullInternetAccess: true }, }); + +// Dynamic URL checking with custom function +const env = new Bash({ + network: { + isAllowed: ({ method, url }) => { + const hostname = new URL(url).hostname; + return hostname.endsWith(".internal.com") || hostname === "api.example.com"; + }, + }, +}); ``` **Note:** The `curl` command only exists when network is configured. Without network configuration, `curl` returns "command not found". diff --git a/src/browser.ts b/src/browser.ts index 74c8ef9f..72b91e6b 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -39,7 +39,7 @@ export type { RmOptions, SymlinkEntry, } from "./fs/interface.js"; -export type { NetworkConfig } from "./network/index.js"; +export type { NetworkConfig, NetworkRequest } from "./network/index.js"; export { NetworkAccessDeniedError, RedirectNotAllowedError, diff --git a/src/index.ts b/src/index.ts index 40d814f7..cfc0981e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,7 +40,7 @@ export { ReadWriteFs, type ReadWriteFsOptions, } from "./fs/read-write-fs/index.js"; -export type { NetworkConfig } from "./network/index.js"; +export type { NetworkConfig, NetworkRequest } from "./network/index.js"; export { NetworkAccessDeniedError, RedirectNotAllowedError, diff --git a/src/network/allow-list/isAllowed.test.ts b/src/network/allow-list/isAllowed.test.ts new file mode 100644 index 00000000..bcdfa62a --- /dev/null +++ b/src/network/allow-list/isAllowed.test.ts @@ -0,0 +1,294 @@ +/** + * Tests for the isAllowed dynamic URL checker function + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { Bash } from "../../Bash.js"; +import { createMockFetch, MOCK_SUCCESS_BODY, originalFetch } from "./shared.js"; + +describe("isAllowed dynamic URL checker", () => { + let mockFetch: ReturnType; + + beforeAll(() => { + mockFetch = createMockFetch(); + global.fetch = mockFetch as typeof fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("sync isAllowed function", () => { + it("allows URLs when isAllowed returns true", async () => { + const env = new Bash({ + network: { + isAllowed: () => true, + }, + }); + + const result = await env.exec("curl https://api.example.com/data"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(MOCK_SUCCESS_BODY); + }); + + it("blocks URLs when isAllowed returns false", async () => { + const env = new Bash({ + network: { + isAllowed: () => false, + }, + }); + + const result = await env.exec("curl https://api.example.com/data"); + expect(result.exitCode).toBe(7); + expect(result.stderr).toContain("Network access denied"); + }); + + it("receives correct method and url in request object", async () => { + const isAllowed = vi.fn().mockReturnValue(true); + const env = new Bash({ + network: { + isAllowed, + allowedMethods: ["GET", "POST"], + }, + }); + + await env.exec("curl -X POST https://api.example.com/data"); + + expect(isAllowed).toHaveBeenCalledWith({ + method: "POST", + url: "https://api.example.com/data", + }); + }); + + it("allows based on hostname check", async () => { + const env = new Bash({ + network: { + isAllowed: ({ url }) => + new URL(url).hostname.endsWith(".example.com"), + }, + }); + + const allowed = await env.exec("curl https://api.example.com/data"); + expect(allowed.exitCode).toBe(0); + + const blocked = await env.exec("curl https://evil.com/data"); + expect(blocked.exitCode).toBe(7); + }); + + it("allows based on method check", async () => { + const env = new Bash({ + network: { + isAllowed: ({ method }) => method === "GET", + allowedMethods: ["GET", "POST"], + }, + }); + + const getResult = await env.exec("curl https://api.example.com/data"); + expect(getResult.exitCode).toBe(0); + + const postResult = await env.exec( + "curl -X POST https://api.example.com/data", + ); + expect(postResult.exitCode).toBe(7); + }); + }); + + describe("async isAllowed function", () => { + it("allows URLs when isAllowed resolves to true", async () => { + const env = new Bash({ + network: { + isAllowed: async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return true; + }, + }, + }); + + const result = await env.exec("curl https://api.example.com/data"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(MOCK_SUCCESS_BODY); + }); + + it("blocks URLs when isAllowed resolves to false", async () => { + const env = new Bash({ + network: { + isAllowed: async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return false; + }, + }, + }); + + const result = await env.exec("curl https://api.example.com/data"); + expect(result.exitCode).toBe(7); + expect(result.stderr).toContain("Network access denied"); + }); + + it("handles async hostname validation", async () => { + const allowedHosts = new Set(["api.example.com"]); + + const env = new Bash({ + network: { + isAllowed: async ({ url }) => { + // Simulate async lookup + await new Promise((resolve) => setTimeout(resolve, 1)); + const hostname = new URL(url).hostname; + return allowedHosts.has(hostname); + }, + }, + }); + + const allowed = await env.exec("curl https://api.example.com/data"); + expect(allowed.exitCode).toBe(0); + + const blocked = await env.exec("curl https://evil.com/data"); + expect(blocked.exitCode).toBe(7); + }); + }); + + describe("isAllowed with redirects", () => { + it("checks redirect targets with isAllowed", async () => { + mockFetch.mockClear(); + const checkedUrls: string[] = []; + + const env = new Bash({ + network: { + isAllowed: ({ url }) => { + checkedUrls.push(url); + return url.includes("api.example.com"); + }, + }, + }); + + // This URL redirects to https://evil.com/data + const result = await env.exec( + "curl https://api.example.com/redirect-to-evil", + ); + + // Should fail because redirect target is blocked (exit code 47 for redirect errors) + expect(result.exitCode).toBe(47); + expect(result.stderr).toContain("Redirect target not in allow-list"); + + // Both URLs should have been checked + expect(checkedUrls).toContain("https://api.example.com/redirect-to-evil"); + expect(checkedUrls).toContain("https://evil.com/data"); + }); + + it("allows redirect chain when all URLs pass isAllowed", async () => { + mockFetch.mockClear(); + + const env = new Bash({ + network: { + isAllowed: ({ url }) => url.includes("api.example.com"), + }, + }); + + // This URL redirects to https://api.example.com/data + const result = await env.exec( + "curl https://api.example.com/redirect-to-allowed", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(MOCK_SUCCESS_BODY); + }); + + it("checks redirect targets with async isAllowed", async () => { + mockFetch.mockClear(); + + const env = new Bash({ + network: { + isAllowed: async ({ url }) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return url.includes("api.example.com"); + }, + }, + }); + + // This URL redirects to https://evil.com/data + const result = await env.exec( + "curl https://api.example.com/redirect-to-evil", + ); + + // Exit code 47 for redirect errors + expect(result.exitCode).toBe(47); + expect(result.stderr).toContain("Redirect target not in allow-list"); + }); + }); + + describe("isAllowed precedence", () => { + it("isAllowed takes precedence over allowedUrlPrefixes", async () => { + const env = new Bash({ + network: { + allowedUrlPrefixes: ["https://api.example.com"], + isAllowed: () => false, // Block everything + }, + }); + + // Even though URL is in allowedUrlPrefixes, isAllowed blocks it + const result = await env.exec("curl https://api.example.com/data"); + expect(result.exitCode).toBe(7); + }); + + it("dangerouslyAllowFullInternetAccess bypasses isAllowed", async () => { + const isAllowed = vi.fn().mockReturnValue(false); + const env = new Bash({ + network: { + dangerouslyAllowFullInternetAccess: true, + isAllowed, + }, + }); + + const result = await env.exec("curl https://api.example.com/data"); + expect(result.exitCode).toBe(0); + // isAllowed should not be called when dangerouslyAllowFullInternetAccess is true + expect(isAllowed).not.toHaveBeenCalled(); + }); + + it("falls back to allowedUrlPrefixes when isAllowed not provided", async () => { + const env = new Bash({ + network: { + allowedUrlPrefixes: ["https://api.example.com"], + }, + }); + + const allowed = await env.exec("curl https://api.example.com/data"); + expect(allowed.exitCode).toBe(0); + + const blocked = await env.exec("curl https://evil.com/data"); + expect(blocked.exitCode).toBe(7); + }); + }); + + describe("isAllowed error handling", () => { + it("lets thrown errors bubble up", async () => { + const env = new Bash({ + network: { + isAllowed: () => { + throw new Error("Auth service unavailable"); + }, + }, + }); + + const result = await env.exec("curl https://api.example.com/data"); + // Error bubbles up as generic error (exit code 1) + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Auth service unavailable"); + }); + + it("lets rejected promises bubble up", async () => { + const env = new Bash({ + network: { + isAllowed: async () => { + throw new Error("Auth service unavailable"); + }, + }, + }); + + const result = await env.exec("curl https://api.example.com/data"); + // Error bubbles up as generic error (exit code 1) + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Auth service unavailable"); + }); + }); +}); diff --git a/src/network/fetch.ts b/src/network/fetch.ts index 3eee2002..9db1bb02 100644 --- a/src/network/fetch.ts +++ b/src/network/fetch.ts @@ -66,16 +66,53 @@ export function createSecureFetch(config: NetworkConfig): SecureFetch { * Checks if a URL is allowed by the configuration. * @throws NetworkAccessDeniedError if the URL is not allowed */ - function checkAllowed(url: string): void { + async function checkAllowed(url: string, method: string): Promise { if (config.dangerouslyAllowFullInternetAccess) { return; } + // Use isAllowed function if provided + if (config.isAllowed) { + const allowed = await config.isAllowed({ method, url }); + if (!allowed) { + throw new NetworkAccessDeniedError(url); + } + return; + } + + // Fall back to static allow list if (!isUrlAllowed(url, config.allowedUrlPrefixes ?? [])) { throw new NetworkAccessDeniedError(url); } } + /** + * Checks if a redirect URL is allowed by the configuration. + * @throws RedirectNotAllowedError if the redirect target is not allowed + */ + async function checkRedirectAllowed( + url: string, + method: string, + ): Promise { + if (config.dangerouslyAllowFullInternetAccess) { + return; + } + + // Use isAllowed function if provided + if (config.isAllowed) { + const allowed = await config.isAllowed({ method, url }); + if (!allowed) { + throw new RedirectNotAllowedError(url); + } + return; + } + + // Fall back to static allow list + if (!isUrlAllowed(url, config.allowedUrlPrefixes ?? [])) { + throw new RedirectNotAllowedError(url); + } + } + /** * Checks if an HTTP method is allowed by the configuration. * @throws MethodNotAllowedError if the method is not allowed @@ -101,7 +138,7 @@ export function createSecureFetch(config: NetworkConfig): SecureFetch { const method = options.method?.toUpperCase() ?? "GET"; // Check if URL and method are allowed - checkAllowed(url); + await checkAllowed(url, method); checkMethodAllowed(method); let currentUrl = url; @@ -149,11 +186,7 @@ export function createSecureFetch(config: NetworkConfig): SecureFetch { const redirectUrl = new URL(location, currentUrl).href; // Check if redirect target is allowed - if (!config.dangerouslyAllowFullInternetAccess) { - if (!isUrlAllowed(redirectUrl, config.allowedUrlPrefixes ?? [])) { - throw new RedirectNotAllowedError(redirectUrl); - } - } + await checkRedirectAllowed(redirectUrl, method); redirectCount++; if (redirectCount > maxRedirects) { diff --git a/src/network/index.ts b/src/network/index.ts index 77fb9965..7a064703 100644 --- a/src/network/index.ts +++ b/src/network/index.ts @@ -15,6 +15,7 @@ export { type HttpMethod, NetworkAccessDeniedError, type NetworkConfig, + type NetworkRequest, RedirectNotAllowedError, TooManyRedirectsError, } from "./types.js"; diff --git a/src/network/types.ts b/src/network/types.ts index 4a6ebb26..a4597689 100644 --- a/src/network/types.ts +++ b/src/network/types.ts @@ -17,6 +17,16 @@ export type HttpMethod = | "PATCH" | "OPTIONS"; +/** + * Request information passed to the isAllowed function + */ +export interface NetworkRequest { + /** The HTTP method (e.g., "GET", "POST") */ + method: string; + /** The full URL being requested */ + url: string; +} + /** * Configuration for network access */ @@ -67,6 +77,24 @@ export interface NetworkConfig { * Responses larger than this will be rejected with ResponseTooLargeError. */ maxResponseSize?: number; + + /** + * Dynamic URL/method checker function. + * Called for each request to determine if it should be allowed. + * Takes precedence over allowedUrlPrefixes when provided. + * + * @example + * // Simple hostname check + * isAllowed: ({ method, url }) => new URL(url).hostname.endsWith('.internal') + * + * @example + * // Async check with external service + * isAllowed: async ({ method, url }) => { + * const response = await authService.checkAccess(url); + * return response.allowed; + * } + */ + isAllowed?: (request: NetworkRequest) => boolean | Promise; } /**