diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 05af410c..3d5d2417 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -76,7 +76,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 24 | WEB-001 | Move to controlled hosting and add browser security policy | P1 | L | proposed | SAFETY-001, CI-001 | | 25 | OBS-001 | Add payment observability, alerts, SLOs, and redaction | P1 | M | proposed | PAY-003, PAY-005 | | 26 | RESILIENCE-001 | Establish backup, restore, retention, and audited repair | P1 | L | proposed | DATA-001, PAY-005 | -| 27 | TEST-001 | Build the Stripe/Firebase security integration and E2E suite | P0 | L | proposed | core security/payment/data children | +| 27 | TEST-001 | Build the Stripe/Firebase security integration and E2E suite | P0 | L | in progress through #175; most children proposed | core security/payment/data children | | 28 | LEGAL-001 | Approve legal, tax, insurance, privacy, refund, and fulfillment policy | P0 | M | blocked_owner_decision | — | | 29 | OPS-001 | Configure production systems and run a controlled live pilot | P0 | L | blocked by all launch gates | CI-001, TEST-001, LEGAL-001, OBS-001, RESILIENCE-001 | @@ -1202,9 +1202,13 @@ Cloud backup configuration changes external state and needs an authorized owner. ## TEST-001 — Build the Stripe/Firebase security integration and E2E suite **Labels:** `priority:P0`, `type:testing`, `type:security`, `area:stripe`, `area:firebase`, `size:L` -**Status:** Proposed +**Status:** In progress through focused child [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175); the wider integration/E2E program remains proposed **Depends on:** Core SEC/AUTH/ABUSE/PAY/RACE/MERCH/MAIL/DATA work +### Current child status + +TEST-001A is split so a small safety foundation can be reviewed before any CI artifact or end-to-end expansion. TEST-001A1 is tracked in [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175): Functions tests receive deterministic reserved fixtures, one shared synthetic Stripe signer, fail-closed checks for supplied production/live configuration, and a Node transport guard that permits only loopback emulator targets. TEST-001A2 remains proposed for bounded emitted-artifact scanning and named CI/release wiring. Neither child authorizes provider calls, production data, Firebase deployment, or live payment testing. + ### Problem Before this planning pass, Functions had seven tests and about 20% line coverage; webhook tests covered only invalid signature/method. Firestore rule tests cover many paths but intentionally asserted unsafe admin access. There is no test proving the cross-system sagas under concurrency/retry/provider failure. diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 699553c4..aebbd677 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -123,8 +123,9 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | WEB-001D | owner_action; A–C | Execute controlled DNS cutover/rollback and retire old deploy path without enabling commerce. | Dated owner-approved cutover evidence. | | RESILIENCE-001A | owner_action; DATA-001B, LEGAL-001 | Inventory RPO/RTO and configure least-privilege monitored backup/export retention. | Private access review and scheduled backup success. | | RESILIENCE-001B | proposed; A, staging | Restore into isolated no-live-Stripe/email/DNS project and validate rules/indexes/counts/references/reconciliation. | Dated timed restore drill and no-external-side-effect proof. | -| TEST-001A | ready; CI-001A | Add synthetic factories, signed Stripe helpers, production-project/live-key/network guards, and artifact scrubber. | Deliberate guard and redaction failures. | -| TEST-001B | proposed; A | Add Auth/Firestore/Functions emulator saga, RBAC, Rules, and App Check integration harness. | Repeatable local/CI demo-project run. | +| TEST-001A1 | delivery tracked in [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175); CI-001A | Add the Functions-only deterministic synthetic factories, shared Stripe signer, supplied-environment rejection, and loopback-only Node test-network guard. | Deliberate unsafe environment/transport failures, fixed redacted errors, existing webhook adoption, full Functions suite, and loopback emulator proof; no runtime/provider claim. | +| TEST-001A2 | proposed; A1 | Add the bounded emitted-artifact scrubber and make its deliberate allow/deny suite a named CI and protected-release gate. | Synthetic artifact fixtures, exact workflow checks, and no source/canary false-positive proof. | +| TEST-001B | proposed; A1–A2 | Add Auth/Firestore/Functions emulator saga, RBAC, Rules, and App Check integration harness. | Repeatable local/CI demo-project run. | | TEST-001C | proposed; B, race/merch reducers | Add deterministic capacity/inventory concurrency and every local/Stripe failure-boundary test. | Repeated no-flake invariant matrix. | | TEST-001D | proposed; B, PAY-003/PAY-005 | Add signed Stripe lifecycle, duplicate/reorder, async, refund/dispute/reconcile fixtures. | Risk/invariant traceability and coverage thresholds. | | TEST-001E | owner_action; A–D, staging | Add browser/test-mode E2E and exact-commit release traceability report. | Redacted staging report; no live objects. | diff --git a/SECURITY.md b/SECURITY.md index f9d70e43..d580cdbb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -104,6 +104,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | RISK-006, part of RISK-024/RISK-032 | #100 merged at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. OAuth secrets and server-owned financial/operational collections are denied; current catalog writes are restricted to inert drafts/display fields. | Firebase deployment skipped. Stage/deploy the exact Rules revision under #105, verify synthetic deny behavior, then move remaining catalog mutation to scoped APIs under ADMIN-001. | | RISK-009 | #99 preserves same-origin path, query, and fragment state through the Pages fallback, clears temporary state, rejects unsafe targets, and suppresses monitoring startup on initial capability callbacks. #126 adds the standalone suite as the named blocking `Run SPA callback safety tests` hosted step. | Keep exact hosted evidence on #126; verify exact website publication and later run approved test-mode Stripe/Strava callbacks; WEB-001 still owns controlled hosting. | | RISK-010 | #99 uses a fully synthetic `demo-mprc-local` configuration, connects Functions/Auth/Firestore to loopback in development/test, routes the direct CSV Function URL locally, fails startup on connector errors, and completed a demo-only three-port CLI smoke. | Current optimized previews still target production; provider calls are not isolated by Firebase. Add protected staging/config and test-provider/sink evidence before end-to-end flows. | +| Test-only foundation for RISK-010 and RISK-035 | TEST-001A1 is tracked in live [#175](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/175). The Functions Jest setup installs a fixed-error, loopback-only guard around supported Node outbound transports and rejects any supplied production-like project/origin/emulator host or live Stripe mode/key. Shared deterministic deeply frozen fixtures use reserved values, and the existing webhook suite adopts one synthetic signed-payload helper. | This is a test-process boundary, not a runtime sandbox. It does not intercept every possible native/add-on transport, isolate an emulated Function process, configure a test provider, scan emitted artifacts, deploy Firebase, or prove live behavior. TEST-001A2 must add the bounded artifact scrubber and named CI/release gate; later TEST-001 children must add emulator sagas and isolated staging evidence. | | Configuration part of RISK-001/RISK-010 | CONFIG-001A [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) merged through PR #150 as `2c62b8dd`, requiring an exact environment/origin/expected Stripe mode, key-mode compatibility on the four key-bound Functions, no production-origin fallback, and no Stripe API key on webhook/mail. CONFIG-001B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds exact `COMMERCE_ENABLED` parsing. A false ceiling denies new commerce before Firestore; otherwise new-commerce commands and every refund perform a fresh strict server-only global/domain/resource admission read. Missing controls/resource flags mean disabled; fixed errors expose no supplied value; Rules tests deny browser control; webhook/mail remain independent. | Neither source result configures or deploys Firebase/Stripe. B1 cannot stop already-admitted commands or existing Sessions/Payment Links and has no safe operator writer. CONFIG-001B2, PAY-002/PAY-004/PAY-005, and #105/#133/#136 retain protected control, expiry/reconciliation, staging, deployment, provider readback, and drill. No live payment or email is authorized. | | Part of RISK-007 | AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) was merged through [PR #107](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/107) as `ce22c110f2132b157bd8a0d43b065585e0b43cb5`. Source and focused tests reject unverified targets at the two existing role-grant endpoints. | The workflow skipped Firebase because `FIREBASE_SERVICE_ACCOUNT` was absent, so backend-live behavior is unproven. Remaining AUTH-001 guards, Rules, verification mirror/refresh, demotion propagation, audit, and authoritative membership work stay open. | | RISK-013 | PROMO-001 [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) sets both current Checkout creators to promotion entry off and automatic tax off. Exact payload and signed event tests require an explicit all-zero breakdown; malformed or nonzero discount, tax, or shipping data creates durable review evidence while definitive failed/expired Sessions still cancel. | Privately inventory pre-change Sessions/provider promotion settings, keep the guard through protected staging/deploy, verify provider and live state separately, and implement an approved authoritative discount/tax/shipping contract before ever enabling it. | diff --git a/functions/package.json b/functions/package.json index 5784ad38..2e4f50cd 100644 --- a/functions/package.json +++ b/functions/package.json @@ -28,5 +28,10 @@ "firebase-functions-test": "^3.1.0", "jest": "^29.7.0" }, + "jest": { + "setupFilesAfterEnv": [ + "/testSupport/jestSafetySetup.js" + ] + }, "private": true } diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index 3fbdaa81..1bcfa62f 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -1,4 +1,4 @@ -const crypto = require('crypto'); +const { createSignedStripePayload } = require('./testSupport/testSafety'); jest.mock('firebase-admin', () => { const store = new Map(); @@ -184,7 +184,7 @@ jest.mock('firebase-admin/firestore', () => { const admin = require('firebase-admin'); -const WEBHOOK_SECRET = 'whsec_testsecret'; +const WEBHOOK_SECRET = 'stripe_webhook_synthetic_test_material'; process.env.ENVIRONMENT_NAME = 'test'; process.env.SITE_ORIGIN = 'https://runmprc.test'; process.env.STRIPE_SECRET_KEY = 'sk_test_testing'; @@ -297,16 +297,16 @@ function seedOrder(overrides = {}) { } function signedRequest(event, signatureOverride) { - const body = JSON.stringify(event); const timestamp = Math.floor(Date.now() / 1000); - const signature = crypto - .createHmac('sha256', WEBHOOK_SECRET) - .update(`${timestamp}.${body}`, 'utf8') - .digest('hex'); - const header = signatureOverride || `t=${timestamp},v1=${signature}`; + const signedPayload = createSignedStripePayload({ + event, + secret: WEBHOOK_SECRET, + timestamp, + }); + const header = signatureOverride || signedPayload.signatureHeader; return { method: 'POST', - rawBody: Buffer.from(body), + rawBody: signedPayload.rawBody, get: (name) => (name.toLowerCase() === 'stripe-signature' ? header : undefined), }; } diff --git a/functions/testSupport/jestSafetySetup.js b/functions/testSupport/jestSafetySetup.js new file mode 100644 index 00000000..15b809a2 --- /dev/null +++ b/functions/testSupport/jestSafetySetup.js @@ -0,0 +1,15 @@ +const { + assertSafeTestEnvironment, + installNetworkGuard, +} = require('./testSafety'); + +assertSafeTestEnvironment(process.env); +installNetworkGuard(); + +beforeEach(() => { + assertSafeTestEnvironment(process.env); +}); + +afterEach(() => { + assertSafeTestEnvironment(process.env); +}); diff --git a/functions/testSupport/testSafety.js b/functions/testSupport/testSafety.js new file mode 100644 index 00000000..dfa0461d --- /dev/null +++ b/functions/testSupport/testSafety.js @@ -0,0 +1,855 @@ +const crypto = require('crypto'); +const dgram = require('dgram'); +const dns = require('dns'); +const http = require('http'); +const http2 = require('http2'); +const https = require('https'); +const net = require('net'); +const tls = require('tls'); + +const TEST_INPUT_ERROR_MESSAGE = 'Unsafe Functions test input.'; +const TEST_ENVIRONMENT_ERROR_MESSAGE = 'Unsafe Functions test environment.'; +const NETWORK_ERROR_MESSAGE = 'External network is blocked in Functions tests.'; + +const CORE_GUARD_STATE = Symbol.for('runmprc.functionsTestSafety.coreGuard'); +const FETCH_GUARD_STATE = Symbol.for('runmprc.functionsTestSafety.fetchGuard'); +const DGRAM_ENDPOINT_STATE = Symbol.for('runmprc.functionsTestSafety.dgramEndpoint'); +const APPROVED_SOCKET_ENDPOINTS = new WeakMap(); +const SAFE_EVENT_TYPES = new Set([ + 'checkout.session.completed', +]); +const PROJECT_ENVIRONMENT_KEYS = [ + 'GCLOUD_PROJECT', + 'GOOGLE_CLOUD_PROJECT', + 'FIREBASE_PROJECT_ID', +]; +const CLOUD_AUTHORITY_KEYS = new Set([ + 'FIREBASE_CREDENTIALS', + 'FIREBASE_SERVICE_ACCOUNT', + 'FIREBASE_SERVICE_ACCOUNT_KEY', + 'FIREBASE_TOKEN', + 'GCLOUD_SERVICE_KEY', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_APPLICATION_CREDENTIALS_JSON', + 'GOOGLE_CLOUD_CREDENTIALS', + 'GOOGLE_CLOUD_KEYFILE_JSON', + 'GOOGLE_SERVICE_ACCOUNT', +]); + +function isCloudAuthorityKey(key) { + if (!/^(?:FIREBASE|GCLOUD|GOOGLE)_/.test(key)) return false; + const tokens = new Set(key.split('_')); + return ['CREDENTIAL', 'CREDENTIALS', 'KEY', 'SECRET', 'TOKEN'] + .some((token) => tokens.has(token)) + || (tokens.has('SERVICE') && tokens.has('ACCOUNT')); +} + +function rejectInput() { + throw new Error(TEST_INPUT_ERROR_MESSAGE); +} + +function rejectEnvironment() { + throw new Error(TEST_ENVIRONMENT_ERROR_MESSAGE); +} + +function rejectNetwork() { + throw new Error(NETWORK_ERROR_MESSAGE); +} + +function deepFreeze(value, seen = new Set()) { + if (value === null || typeof value !== 'object' || seen.has(value)) return value; + seen.add(value); + Reflect.ownKeys(value).forEach((key) => deepFreeze(value[key], seen)); + return Object.freeze(value); +} + +function syntheticToken(seed) { + if (!Number.isSafeInteger(seed) || seed < 0 || seed > 999999) rejectInput(); + return seed.toString(10).padStart(6, '0'); +} + +function createSyntheticContact(seed = 1) { + if (arguments.length > 1) rejectInput(); + const token = syntheticToken(seed); + const reservedPhoneSuffix = (100 + (seed % 100)).toString(10); + return deepFreeze({ + fixture: 'synthetic-contact-v1', + fullName: `Synthetic Runner ${token}`, + email: `runner-${token}@example.test`, + phoneNumber: `+12025550${reservedPhoneSuffix}`, + address: { + line1: `${100 + (seed % 900)} Test Only Avenue`, + line2: null, + city: 'Example', + state: 'CA', + postalCode: '00000', + country: 'US', + }, + }); +} + +function createSyntheticMember(seed = 1) { + if (arguments.length > 1) rejectInput(); + const token = syntheticToken(seed); + return deepFreeze({ + fixture: 'synthetic-member-v1', + uid: `member_test_${token}`, + emailVerified: true, + membershipStatus: 'synthetic_test_only', + contact: createSyntheticContact(seed), + }); +} + +function createSyntheticOrder(seed = 1) { + if (arguments.length > 1) rejectInput(); + const token = syntheticToken(seed); + return deepFreeze({ + fixture: 'synthetic-order-v1', + id: `order_test_${token}`, + memberId: `member_test_${token}`, + status: 'pending', + amountCents: 2500 + (seed % 500), + currency: 'usd', + contact: createSyntheticContact(seed), + items: [{ + sku: 'synthetic_test_item', + quantity: 1, + }], + }); +} + +function createSyntheticStripeEvent(seed = 1, type = 'checkout.session.completed') { + if (arguments.length > 2) rejectInput(); + const token = syntheticToken(seed); + if (!SAFE_EVENT_TYPES.has(type)) rejectInput(); + return deepFreeze({ + id: `evt_test_${token}`, + object: 'event', + api_version: '2023-10-16', + created: 1800000000 + seed, + livemode: false, + pending_webhooks: 1, + request: null, + type, + data: { + object: { + id: `cs_test_${token}`, + object: 'checkout.session', + livemode: false, + mode: 'payment', + payment_status: 'paid', + amount_total: 2500 + (seed % 500), + currency: 'usd', + metadata: { + fixture: 'synthetic_test_only', + orderId: `order_test_${token}`, + }, + }, + }, + }); +} + +function isPlainRecord(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isSyntheticSecret(secret) { + return typeof secret === 'string' + && secret.length >= 12 + && secret.length <= 128 + && /^[A-Za-z0-9_-]+$/.test(secret) + && /(test|synthetic|example|demo)/.test(secret) + && !/^(?:sk|rk|pk)_live_/.test(secret) + && !/^whsec_/.test(secret); +} + +function createSignedStripePayload(input) { + try { + if (!isPlainRecord(input)) rejectInput(); + const inputDescriptors = Object.getOwnPropertyDescriptors(input); + const expectedInputKeys = ['event', 'secret', 'timestamp']; + if (Reflect.ownKeys(inputDescriptors).length !== expectedInputKeys.length + || expectedInputKeys.some((key) => ( + !Object.prototype.hasOwnProperty.call(inputDescriptors, key) + || !Object.prototype.hasOwnProperty.call(inputDescriptors[key], 'value') + ))) { + rejectInput(); + } + const event = inputDescriptors.event.value; + const secret = inputDescriptors.secret.value; + const timestamp = inputDescriptors.timestamp.value; + + return createSignedStripePayloadFromValues(event, secret, timestamp); + } catch { + rejectInput(); + } +} + +function createSignedStripePayloadFromValues(event, secret, timestamp) { + const eventDescriptors = isPlainRecord(event) ? Object.getOwnPropertyDescriptors(event) : null; + if (!isPlainRecord(event) + || !eventDescriptors + || Reflect.ownKeys(eventDescriptors).some((key) => ( + typeof key !== 'string' + || !Object.prototype.hasOwnProperty.call(eventDescriptors[key], 'value') + || typeof eventDescriptors[key].value === 'function' + )) + || event.object !== 'event' + || typeof event.livemode !== 'boolean' + || !isPlainRecord(event.data) + || !isSyntheticSecret(secret) + || !Number.isSafeInteger(timestamp) + || timestamp < 1 + || timestamp > 4102444800) { + rejectInput(); + } + + const serialized = JSON.stringify(event); + if (typeof serialized !== 'string' || serialized.length < 2 || serialized.length > 1048576) { + rejectInput(); + } + const serializedEvent = JSON.parse(serialized); + if (!isPlainRecord(serializedEvent) + || serializedEvent.object !== 'event' + || typeof serializedEvent.livemode !== 'boolean' + || !isPlainRecord(serializedEvent.data)) { + rejectInput(); + } + + const rawBody = Buffer.from(serialized, 'utf8'); + if (rawBody.length > 1048576) rejectInput(); + const digest = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.`, 'utf8') + .update(rawBody) + .digest('hex'); + return Object.freeze({ + rawBody, + signatureHeader: `t=${timestamp},v1=${digest}`, + }); +} + +function supplied(environment, key) { + return Object.prototype.hasOwnProperty.call(environment, key) + && environment[key] !== undefined; +} + +function isSafeProjectId(projectId) { + return typeof projectId === 'string' + && projectId.length >= 10 + && projectId.length <= 63 + && /^demo-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(projectId) + && /(?:^|-)(?:test|local)(?:-|$)/.test(projectId); +} + +function normalizeLoopbackHost(host) { + if (host === '[::1]' || host === '::1') return '::1'; + if (host === '127.0.0.1' || host === 'localhost') return host; + return null; +} + +function parseEmulatorHost(value) { + if (typeof value !== 'string' || value === '' || value.trim() !== value + || value.includes('/') || value.includes('@') || value.includes('?') || value.includes('#')) { + rejectEnvironment(); + } + let parsed; + try { + parsed = new URL(`http://${value}`); + } catch { + rejectEnvironment(); + } + const host = normalizeLoopbackHost(parsed.hostname); + const port = Number(parsed.port); + if (!host + || parsed.username !== '' + || parsed.password !== '' + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '' + || !Number.isInteger(port) + || port < 1 + || port > 65535) { + rejectEnvironment(); + } + return { host, port }; +} + +function isSafeTestDnsName(hostname) { + if (!hostname.endsWith('.test') || hostname === '.test' || hostname.length > 253) return false; + return hostname.split('.').every((label) => ( + label.length >= 1 + && label.length <= 63 + && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label) + )); +} + +function assertSafeOrigin(value) { + if (typeof value !== 'string' || value === '' || value.trim() !== value) rejectEnvironment(); + let parsed; + try { + parsed = new URL(value); + } catch { + rejectEnvironment(); + } + if (parsed.origin !== value + || parsed.username !== '' + || parsed.password !== '' + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '') { + rejectEnvironment(); + } + const loopback = normalizeLoopbackHost(parsed.hostname); + if (loopback) { + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.port === '') rejectEnvironment(); + return; + } + if (parsed.protocol !== 'https:' || parsed.port !== '' || !isSafeTestDnsName(parsed.hostname)) { + rejectEnvironment(); + } +} + +function assertSafeStripeValue(key, value) { + if (typeof value !== 'string' || value === '' || value.trim() !== value) rejectEnvironment(); + const keyTokens = new Set(key.split('_')); + if (keyTokens.has('LIVEMODE')) { + if (value !== 'false') rejectEnvironment(); + return; + } + if (keyTokens.has('MODE')) { + if (value !== 'test') rejectEnvironment(); + return; + } + if (keyTokens.has('KEY') || keyTokens.has('SECRET')) { + if (!isSyntheticSecret(value)) rejectEnvironment(); + } +} + +function assertSafeTestEnvironment(environment = process.env) { + try { + return assertSafeTestEnvironmentValues(environment); + } catch { + rejectEnvironment(); + } +} + +function assertSafeTestEnvironmentValues(environment) { + if (!environment || typeof environment !== 'object') rejectEnvironment(); + + if (supplied(environment, 'ENVIRONMENT_NAME') + && !['local', 'test'].includes(environment.ENVIRONMENT_NAME)) { + rejectEnvironment(); + } + if (supplied(environment, 'SITE_ORIGIN')) assertSafeOrigin(environment.SITE_ORIGIN); + + PROJECT_ENVIRONMENT_KEYS.forEach((key) => { + if (supplied(environment, key) && !isSafeProjectId(environment[key])) rejectEnvironment(); + }); + + if (supplied(environment, 'FIREBASE_CONFIG')) { + let config; + try { + config = JSON.parse(environment.FIREBASE_CONFIG); + } catch { + rejectEnvironment(); + } + const allowedKeys = new Set(['databaseURL', 'projectId', 'storageBucket']); + const keys = isPlainRecord(config) ? Object.keys(config) : []; + if (!isPlainRecord(config) + || !isSafeProjectId(config.projectId) + || keys.some((key) => !allowedKeys.has(key)) + || (config.databaseURL !== undefined + && ![ + `https://${config.projectId}.firebaseio.com`, + `https://${config.projectId}-default-rtdb.firebaseio.com`, + ].includes(config.databaseURL)) + || (config.storageBucket !== undefined + && config.storageBucket !== `${config.projectId}.appspot.com`)) { + rejectEnvironment(); + } + } + + Object.keys(environment).forEach((key) => { + if (CLOUD_AUTHORITY_KEYS.has(key) || isCloudAuthorityKey(key)) { + rejectEnvironment(); + } + if (key.endsWith('_EMULATOR_HOST')) parseEmulatorHost(environment[key]); + const stripeKeyTokens = key.startsWith('STRIPE_') ? new Set(key.split('_')) : null; + if (stripeKeyTokens + && ['KEY', 'SECRET', 'MODE', 'LIVEMODE'] + .some((token) => stripeKeyTokens.has(token))) { + assertSafeStripeValue(key, environment[key]); + } + }); + return true; +} + +function configuredEmulatorEndpoints(environment = process.env) { + assertSafeTestEnvironment(environment); + const endpoints = new Set(); + Object.keys(environment).forEach((key) => { + if (!key.endsWith('_EMULATOR_HOST')) return; + const { host, port } = parseEmulatorHost(environment[key]); + endpoints.add(`${host}:${port}`); + }); + return endpoints; +} + +function assertEndpointAllowed(host, port, environment) { + const selectedEnvironment = environment + || http[CORE_GUARD_STATE]?.environment + || process.env; + const normalizedHost = normalizeLoopbackHost(host); + let normalizedPort; + if (typeof port === 'number') normalizedPort = port; + else if (typeof port === 'string' + && /^[1-9][0-9]{0,4}$/.test(port)) { + normalizedPort = Number.parseInt(port, 10); + if (String(normalizedPort) !== port) rejectNetwork(); + } else { + rejectNetwork(); + } + if (!normalizedHost + || !Number.isInteger(normalizedPort) + || normalizedPort < 1 + || normalizedPort > 65535 + || !configuredEmulatorEndpoints(selectedEnvironment).has(`${normalizedHost}:${normalizedPort}`)) { + rejectNetwork(); + } +} + +function endpointFromUrl(value, expectedProtocols) { + let parsed; + try { + parsed = value instanceof URL ? value : new URL(value); + } catch { + rejectNetwork(); + } + if (!expectedProtocols.includes(parsed.protocol) + || parsed.username !== '' + || parsed.password !== '') { + rejectNetwork(); + } + const port = parsed.port || (parsed.protocol === 'https:' ? 443 : 80); + return { host: parsed.hostname, port }; +} + +function rejectRoutingOptions(options, forbiddenKeys) { + if (options === undefined || options === null || typeof options !== 'object') return options; + let prototype; + let descriptors; + try { + prototype = Object.getPrototypeOf(options); + descriptors = Object.getOwnPropertyDescriptors(options); + } catch { + rejectNetwork(); + } + try { + if (prototype !== null && Object.getPrototypeOf(prototype) !== null) rejectNetwork(); + } catch { + rejectNetwork(); + } + for (const forbiddenKey of forbiddenKeys) { + let cursor = options; + try { + while (cursor !== null) { + const descriptor = Object.getOwnPropertyDescriptor(cursor, forbiddenKey); + if (descriptor + && descriptor.value !== undefined + && descriptor.value !== null) { + rejectNetwork(); + } + cursor = Object.getPrototypeOf(cursor); + } + } catch { + rejectNetwork(); + } + } + const snapshot = Object.create(null); + for (const key of Reflect.ownKeys(descriptors)) { + const descriptor = descriptors[key]; + if (typeof key !== 'string' + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || (forbiddenKeys.includes(key) + && descriptor.value !== undefined + && descriptor.value !== null)) { + rejectNetwork(); + } + Object.defineProperty(snapshot, key, { + configurable: true, + enumerable: descriptor.enumerable, + value: descriptor.value, + writable: true, + }); + } + return snapshot; +} + +function syntheticDnsLookup(hostname, rawOptions) { + let options = rawOptions; + if (options === undefined) options = {}; + else if (Number.isInteger(options)) options = { family: options }; + else options = rejectRoutingOptions(options, []); + if (!options || typeof options !== 'object' + || Object.keys(options).some((key) => ![ + 'all', 'family', 'hints', 'order', 'verbatim', + ].includes(key)) + || (options.family !== undefined && ![0, 4, 6].includes(options.family)) + || (options.hints !== undefined + && (!Number.isInteger(options.hints) || options.hints < 0)) + || (options.all !== undefined && typeof options.all !== 'boolean')) { + rejectNetwork(); + } + + let address; + let family; + if (hostname === '127.0.0.1' && options.family !== 6) { + address = hostname; + family = 4; + } else if (hostname === '::1' && options.family !== 4) { + address = hostname; + family = 6; + } else if (hostname === '0.0.0.0' && options.family !== 6) { + address = hostname; + family = 4; + } else if (hostname === '::' && options.family !== 4) { + address = hostname; + family = 6; + } else if (hostname === 'localhost') { + family = options.family === 6 ? 6 : 4; + address = family === 6 ? '::1' : '127.0.0.1'; + } else { + rejectNetwork(); + } + return options.all ? [{ address, family }] : { address, family }; +} + +function endpointFromHttpArguments(args, defaultProtocol) { + const first = args[0]; + const optionsIndex = (typeof first === 'string' || first instanceof URL) ? 1 : 0; + const options = args[optionsIndex]; + let endpoint = (typeof first === 'string' || first instanceof URL) + ? endpointFromUrl(first, [`${defaultProtocol}:`]) + : null; + if (options && typeof options === 'object') { + const safeOptions = rejectRoutingOptions(options, [ + 'agent', + 'createConnection', + '_defaultAgent', + 'lookup', + 'localAddress', + 'socket', + 'socketPath', + ]); + args[optionsIndex] = safeOptions; + const protocol = safeOptions.protocol || `${defaultProtocol}:`; + if (protocol !== `${defaultProtocol}:`) rejectNetwork(); + const host = safeOptions.hostname || safeOptions.host || endpoint?.host || 'localhost'; + const port = safeOptions.port || endpoint?.port || (defaultProtocol === 'https' ? 443 : 80); + endpoint = { host, port }; + } + if (!endpoint) rejectNetwork(); + return endpoint; +} + +function endpointFromSocketArguments(args, defaultPort) { + const normalized = Array.isArray(args[0]) ? args[0] : null; + const first = normalized ? normalized[0] : args[0]; + const routingKeys = [ + 'createConnection', + 'fd', + 'handle', + 'localAddress', + 'lookup', + 'path', + 'socket', + ]; + if (first && typeof first === 'object') { + const safeOptions = rejectRoutingOptions(first, routingKeys); + if (normalized) normalized[0] = safeOptions; + else args[0] = safeOptions; + return { + host: safeOptions.host || safeOptions.hostname || 'localhost', + port: safeOptions.port || defaultPort, + }; + } + const laterOptions = args.find((value, index) => index > 0 + && value !== null + && typeof value === 'object'); + const safeLaterOptions = rejectRoutingOptions(laterOptions, routingKeys); + if (laterOptions) args[args.indexOf(laterOptions)] = safeLaterOptions; + return { + host: typeof args[1] === 'string' + ? args[1] + : (safeLaterOptions?.host || safeLaterOptions?.hostname || 'localhost'), + port: first || defaultPort, + }; +} + +function guardHttpModule(module, protocol) { + const originalRequest = module.request; + const originalGet = module.get; + module.request = function guardedRequest(...args) { + const endpoint = endpointFromHttpArguments(args, protocol); + assertEndpointAllowed(endpoint.host, endpoint.port); + return Reflect.apply(originalRequest, this, args); + }; + module.get = function guardedGet(...args) { + const endpoint = endpointFromHttpArguments(args, protocol); + assertEndpointAllowed(endpoint.host, endpoint.port); + return Reflect.apply(originalGet, this, args); + }; +} + +function guardDirectHttpClientRequest() { + const OriginalClientRequest = http.ClientRequest; + function GuardedClientRequest(...args) { + const endpoint = endpointFromHttpArguments(args, 'http'); + assertEndpointAllowed(endpoint.host, endpoint.port); + const constructor = new.target === GuardedClientRequest + ? OriginalClientRequest + : (new.target || OriginalClientRequest); + return Reflect.construct(OriginalClientRequest, args, constructor); + } + Object.setPrototypeOf(GuardedClientRequest, OriginalClientRequest); + GuardedClientRequest.prototype = OriginalClientRequest.prototype; + http.ClientRequest = GuardedClientRequest; +} + +function installCoreNetworkGuard() { + if (http[CORE_GUARD_STATE]) { + http[CORE_GUARD_STATE].environment = process.env; + return http[CORE_GUARD_STATE]; + } + + guardHttpModule(http, 'http'); + guardHttpModule(https, 'https'); + guardDirectHttpClientRequest(); + + const originalHttp2Connect = http2.connect; + http2.connect = function guardedHttp2Connect(authority, ...args) { + const safeOptions = rejectRoutingOptions(args[0], [ + 'localAddress', + 'lookup', + 'path', + 'socket', + ]); + const endpoint = endpointFromUrl(authority, ['http:', 'https:']); + assertEndpointAllowed(endpoint.host, endpoint.port); + if (safeOptions && typeof safeOptions === 'object' + && safeOptions.createConnection !== undefined + && safeOptions.createConnection !== null) { + if (typeof safeOptions.createConnection !== 'function') rejectNetwork(); + const createConnection = safeOptions.createConnection; + let socket; + try { + const parsedAuthority = authority instanceof URL ? authority : new URL(authority); + socket = createConnection(parsedAuthority, safeOptions); + } catch (error) { + if (error?.message === NETWORK_ERROR_MESSAGE) throw error; + rejectNetwork(); + } + const marker = socket && typeof socket === 'object' + ? APPROVED_SOCKET_ENDPOINTS.get(socket) + : null; + const expectedHost = normalizeLoopbackHost(endpoint.host); + const hasRemoteHost = typeof socket?.remoteAddress === 'string' + && socket.remoteAddress !== ''; + const remoteHost = hasRemoteHost ? normalizeLoopbackHost(socket.remoteAddress) : null; + const remoteHostMatches = !hasRemoteHost + || remoteHost === expectedHost + || (expectedHost === 'localhost' && ['127.0.0.1', '::1'].includes(remoteHost)); + if (!marker + || marker.host !== expectedHost + || marker.port !== Number(endpoint.port) + || !remoteHostMatches + || (socket.remotePort !== undefined + && socket.remotePort !== Number(endpoint.port))) { + if (socket && typeof socket.destroy === 'function') socket.destroy(); + rejectNetwork(); + } + safeOptions.createConnection = () => socket; + } + args[0] = safeOptions; + return Reflect.apply(originalHttp2Connect, this, [authority, ...args]); + }; + + const originalSocketConnect = net.Socket.prototype.connect; + net.Socket.prototype.connect = function guardedSocketConnect(...args) { + const endpoint = endpointFromSocketArguments(args); + assertEndpointAllowed(endpoint.host, endpoint.port); + const result = Reflect.apply(originalSocketConnect, this, args); + APPROVED_SOCKET_ENDPOINTS.set(this, Object.freeze({ + host: normalizeLoopbackHost(endpoint.host), + port: Number(endpoint.port), + })); + return result; + }; + + const originalNetConnect = net.createConnection; + const guardedNetConnect = function guardedNetCreateConnection(...args) { + if (args[0] && typeof args[0] === 'object' && !Array.isArray(args[0])) { + args[0] = rejectRoutingOptions(args[0], [ + 'createConnection', + 'fd', + 'handle', + 'localAddress', + 'lookup', + 'path', + 'socket', + ]); + } + return Reflect.apply(originalNetConnect, this, args); + }; + net.connect = guardedNetConnect; + net.createConnection = guardedNetConnect; + + const originalTlsConnect = tls.connect; + tls.connect = function guardedTlsConnect(...args) { + const endpoint = endpointFromSocketArguments(args, 443); + assertEndpointAllowed(endpoint.host, endpoint.port); + return Reflect.apply(originalTlsConnect, this, args); + }; + + const originalCreateDgramSocket = dgram.createSocket; + dgram.createSocket = function guardedCreateDgramSocket(options, ...args) { + const safeOptions = rejectRoutingOptions(options, ['lookup', 'path', 'socket']); + return Reflect.apply(originalCreateDgramSocket, this, [safeOptions, ...args]); + }; + + const originalDgramConnect = dgram.Socket.prototype.connect; + dgram.Socket.prototype.connect = function guardedDgramConnect(port, address, ...args) { + const endpoint = { + host: typeof address === 'string' ? address : 'localhost', + port, + }; + assertEndpointAllowed(endpoint.host, endpoint.port); + Object.defineProperty(this, DGRAM_ENDPOINT_STATE, { + configurable: true, + value: endpoint, + }); + return Reflect.apply(originalDgramConnect, this, [port, address, ...args]); + }; + const originalDgramSend = dgram.Socket.prototype.send; + dgram.Socket.prototype.send = function guardedDgramSend(...args) { + let port; + let address = 'localhost'; + if (typeof args[1] === 'number' && typeof args[2] === 'number') { + port = args[3]; + if (typeof args[4] === 'string') address = args[4]; + } else { + port = args[1]; + if (typeof args[2] === 'string') address = args[2]; + } + if (!Number.isInteger(port) && this[DGRAM_ENDPOINT_STATE]) { + ({ host: address, port } = this[DGRAM_ENDPOINT_STATE]); + } + assertEndpointAllowed(address, port); + return Reflect.apply(originalDgramSend, this, args); + }; + + const dnsMethodNames = [ + 'lookupService', + 'resolve', + 'resolve4', + 'resolve6', + 'resolveAny', + 'resolveCaa', + 'resolveCname', + 'resolveMx', + 'resolveNaptr', + 'resolveNs', + 'resolvePtr', + 'resolveSoa', + 'resolveSrv', + 'resolveTxt', + 'reverse', + ]; + const blockDnsMethods = (target) => { + if (!target) return; + dnsMethodNames.forEach((method) => { + if (typeof target[method] !== 'function') return; + target[method] = function blockedDnsCall() { + rejectNetwork(); + }; + }); + }; + blockDnsMethods(dns); + blockDnsMethods(dns.promises); + blockDnsMethods(dns.Resolver?.prototype); + blockDnsMethods(dns.promises?.Resolver?.prototype); + dns.lookup = function guardedDnsLookup(hostname, options, callback) { + let actualOptions = options; + let actualCallback = callback; + if (typeof options === 'function') { + actualOptions = undefined; + actualCallback = options; + } + if (typeof actualCallback !== 'function') rejectNetwork(); + const result = syntheticDnsLookup(hostname, actualOptions); + process.nextTick(() => { + if (Array.isArray(result)) actualCallback(null, result); + else actualCallback(null, result.address, result.family); + }); + }; + dns.promises.lookup = function guardedDnsPromisesLookup(hostname, options) { + const result = syntheticDnsLookup(hostname, options); + return Promise.resolve(result); + }; + + const state = { installed: true, environment: process.env }; + Object.defineProperty(http, CORE_GUARD_STATE, { value: state }); + return state; +} + +function installFetchNetworkGuard() { + if (globalThis[FETCH_GUARD_STATE]) return globalThis[FETCH_GUARD_STATE]; + if (typeof globalThis.fetch !== 'function') { + const state = Object.freeze({ installed: false }); + Object.defineProperty(globalThis, FETCH_GUARD_STATE, { value: state }); + return state; + } + const originalFetch = globalThis.fetch; + globalThis.fetch = function guardedFetch(input, ...args) { + args[0] = rejectRoutingOptions(args[0], [ + 'agent', + 'createConnection', + 'dispatcher', + 'lookup', + 'socket', + 'socketPath', + ]); + const target = typeof input === 'string' || input instanceof URL ? input : input?.url; + const endpoint = endpointFromUrl(target, ['http:', 'https:']); + assertEndpointAllowed(endpoint.host, endpoint.port); + return Reflect.apply(originalFetch, this, [input, ...args]); + }; + const state = Object.freeze({ installed: true }); + Object.defineProperty(globalThis, FETCH_GUARD_STATE, { value: state }); + return state; +} + +function installNetworkGuard() { + assertSafeTestEnvironment(process.env); + installCoreNetworkGuard(); + installFetchNetworkGuard(); + return true; +} + +module.exports = Object.freeze({ + NETWORK_ERROR_MESSAGE, + TEST_ENVIRONMENT_ERROR_MESSAGE, + TEST_INPUT_ERROR_MESSAGE, + assertEndpointAllowed, + assertSafeTestEnvironment, + createSignedStripePayload, + createSyntheticContact, + createSyntheticMember, + createSyntheticOrder, + createSyntheticStripeEvent, + deepFreeze, + installNetworkGuard, +}); diff --git a/functions/testSupport/testSafety.test.js b/functions/testSupport/testSafety.test.js new file mode 100644 index 00000000..8a3b5230 --- /dev/null +++ b/functions/testSupport/testSafety.test.js @@ -0,0 +1,545 @@ +const dgram = require('dgram'); +const dns = require('dns'); +const http = require('http'); +const http2 = require('http2'); +const https = require('https'); +const net = require('net'); +const tls = require('tls'); + +const { + NETWORK_ERROR_MESSAGE, + TEST_ENVIRONMENT_ERROR_MESSAGE, + TEST_INPUT_ERROR_MESSAGE, + assertEndpointAllowed, + assertSafeTestEnvironment, + createSignedStripePayload, + createSyntheticContact, + createSyntheticMember, + createSyntheticOrder, + createSyntheticStripeEvent, + installNetworkGuard, +} = require('./testSafety'); + +const SYNTHETIC_SIGNING_MATERIAL = 'stripe_webhook_synthetic_test_material'; +const LIVE_KEY_CANARY = ['sk', 'live', 'hostile', 'canary', 'material'].join('_'); +const WEBHOOK_SECRET_CANARY = ['whsec', 'hostile', 'canary', 'material'].join('_'); + +function captureError(callback) { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected a fixed safety error'); +} + +function expectSafeError(callback, expectedMessage, hostileValue) { + const error = captureError(callback); + expect(error).toBeTruthy(); + expect(error.message).toBe(expectedMessage); + expect(JSON.stringify(error)).toBe('{}'); + if (hostileValue) { + expect(error.message).not.toContain(hostileValue); + expect(JSON.stringify(error)).not.toContain(hostileValue); + expect(error.stack).not.toContain(hostileValue); + } +} + +function expectDeeplyFrozen(value) { + expect(Object.isFrozen(value)).toBe(true); + if (value === null || typeof value !== 'object') return; + Reflect.ownKeys(value).forEach((key) => expectDeeplyFrozen(value[key])); +} + +describe('synthetic Functions fixtures', () => { + test.each([ + ['contact', createSyntheticContact], + ['member', createSyntheticMember], + ['order', createSyntheticOrder], + ['Stripe event', createSyntheticStripeEvent], + ])('%s fixture is deterministic, fresh, and deeply frozen', (_name, factory) => { + const first = factory(42); + const second = factory(42); + + expect(first).toEqual(second); + expect(first).not.toBe(second); + expectDeeplyFrozen(first); + expect(JSON.stringify(first)).toContain('test'); + expect(JSON.stringify(first)).not.toContain('runmprc.com'); + }); + + test('uses reserved, non-deliverable contact data', () => { + const contact = createSyntheticContact(7); + + expect(contact.email).toMatch(/@example\.test$/); + expect(contact.phoneNumber).toMatch(/^\+120255501\d{2}$/); + expect(contact.address.postalCode).toBe('00000'); + expect(contact.fullName).toMatch(/^Synthetic Runner /); + }); + + test.each([ + () => createSyntheticContact(-1), + () => createSyntheticMember('real-person'), + () => createSyntheticOrder(Number.MAX_SAFE_INTEGER), + () => createSyntheticMember(1, { email: 'hostile-personal-input.invalid' }), + () => createSyntheticStripeEvent(1, 'customer.created'), + () => createSyntheticStripeEvent(1, 'checkout.session.async_payment_failed'), + () => createSyntheticStripeEvent(1, 'checkout.session.expired'), + () => createSyntheticStripeEvent(1, 'checkout.session.completed', { livemode: true }), + ])('rejects unsafe seeds, event types, and override-shaped inputs', (createFixture) => { + expectSafeError(createFixture, TEST_INPUT_ERROR_MESSAGE, 'hostile-personal-input.invalid'); + }); + + test('signs exact raw event bytes deterministically without returning the secret', () => { + const event = createSyntheticStripeEvent(9); + const first = createSignedStripePayload({ + event, + secret: SYNTHETIC_SIGNING_MATERIAL, + timestamp: 1800000009, + }); + const second = createSignedStripePayload({ + event, + secret: SYNTHETIC_SIGNING_MATERIAL, + timestamp: 1800000009, + }); + + expect(first.rawBody).toEqual(Buffer.from(JSON.stringify(event), 'utf8')); + expect(first.signatureHeader).toBe(second.signatureHeader); + expect(first.signatureHeader).toMatch(/^t=1800000009,v1=[a-f0-9]{64}$/); + expect(Object.isFrozen(first)).toBe(true); + expect(JSON.stringify(first)).not.toContain(SYNTHETIC_SIGNING_MATERIAL); + expect(first.signatureHeader).not.toContain(SYNTHETIC_SIGNING_MATERIAL); + }); + + test.each([ + [null, 'hostile-signing-input-canary'], + [new Proxy({}, { + get() { + throw new Error('hostile-signing-input-canary'); + }, + getPrototypeOf() { + throw new Error('hostile-signing-input-canary'); + }, + }), 'hostile-signing-input-canary'], + [Object.defineProperty({}, 'event', { + get() { + throw new Error('hostile-signing-input-canary'); + }, + }), 'hostile-signing-input-canary'], + [{ event: undefined, secret: SYNTHETIC_SIGNING_MATERIAL, timestamp: 1800000000 }], + [{ event: createSyntheticStripeEvent(1), secret: 'hostile-live-like-material', timestamp: 1800000000 }], + [{ event: createSyntheticStripeEvent(1), secret: SYNTHETIC_SIGNING_MATERIAL, timestamp: 0 }], + [{ event: { object: 'event', livemode: 'true', data: {} }, secret: SYNTHETIC_SIGNING_MATERIAL, timestamp: 1800000000 }], + [{ + event: { + ...createSyntheticStripeEvent(1), + toJSON() { + return { ...createSyntheticStripeEvent(1), livemode: true }; + }, + }, + secret: SYNTHETIC_SIGNING_MATERIAL, + timestamp: 1800000000, + }], + [{ + event: { + ...createSyntheticStripeEvent(1), + data: { object: { syntheticText: '😀'.repeat(300000) } }, + }, + secret: SYNTHETIC_SIGNING_MATERIAL, + timestamp: 1800000000, + }], + ])('rejects malformed or unsafe Stripe signing input', (input, canary = 'hostile-live-like-material') => { + expectSafeError( + () => createSignedStripePayload(input), + TEST_INPUT_ERROR_MESSAGE, + canary, + ); + }); +}); + +describe('Functions test environment safety', () => { + test.each([ + {}, + { + ENVIRONMENT_NAME: 'test', + SITE_ORIGIN: 'https://functions.example.test', + GCLOUD_PROJECT: 'demo-functions-test', + FIRESTORE_EMULATOR_HOST: '127.0.0.1:8080', + FIREBASE_AUTH_EMULATOR_HOST: 'localhost:9099', + STRIPE_LIVEMODE_EXPECTED: 'false', + STRIPE_SECRET_KEY: 'sk_test_synthetic_functions_key', + STRIPE_WEBHOOK_SECRET: SYNTHETIC_SIGNING_MATERIAL, + }, + { + ENVIRONMENT_NAME: 'local', + SITE_ORIGIN: 'http://[::1]:5001', + GOOGLE_CLOUD_PROJECT: 'demo-mprc-local', + FIREBASE_CONFIG: JSON.stringify({ + databaseURL: 'https://demo-mprc-local.firebaseio.com', + projectId: 'demo-mprc-local', + storageBucket: 'demo-mprc-local.appspot.com', + }), + FUNCTIONS_EMULATOR_HOST: '[::1]:5001', + }, + ])('accepts missing optional values and explicit synthetic environments', (environment) => { + expect(assertSafeTestEnvironment(environment)).toBe(true); + }); + + test.each([ + ['production environment', { ENVIRONMENT_NAME: 'production' }, 'production'], + ['production project', { GCLOUD_PROJECT: 'mid-peninsula-running-club' }, 'mid-peninsula-running-club'], + ['malformed Firebase config', { FIREBASE_CONFIG: 'hostile-config-text' }, 'hostile-config-text'], + ['Firebase config with database authority', { + FIREBASE_CONFIG: JSON.stringify({ + projectId: 'demo-functions-test', + databaseURL: 'https://hostile-database.invalid', + }), + }, 'hostile-database.invalid'], + ['Firebase config with browser authority', { + FIREBASE_CONFIG: JSON.stringify({ + projectId: 'demo-functions-test', + apiKey: 'hostile-browser-authority', + }), + }, 'hostile-browser-authority'], + ['Firebase service account', { FIREBASE_SERVICE_ACCOUNT: 'hostile-service-account' }, 'hostile-service-account'], + ['Firebase CLI token', { FIREBASE_TOKEN: 'hostile-firebase-token' }, 'hostile-firebase-token'], + ['Google credential path', { GOOGLE_APPLICATION_CREDENTIALS: '/hostile/credential/path' }, '/hostile/credential/path'], + ['rotating Google credential path', { + GOOGLE_APPLICATION_CREDENTIALS_NEXT: '/hostile/credential/path', + }, '/hostile/credential/path'], + ['Google access token', { GOOGLE_OAUTH_ACCESS_TOKEN: 'hostile-google-token' }, 'hostile-google-token'], + ['rotating Google Cloud token', { + GCLOUD_ACCESS_TOKEN_ROTATING: 'hostile-google-token', + }, 'hostile-google-token'], + ['Google API key', { GOOGLE_API_KEY: 'hostile-google-api-key' }, 'hostile-google-api-key'], + ['Google ID token', { GOOGLE_ID_TOKEN: 'hostile-google-id-token' }, 'hostile-google-id-token'], + ['Firebase API key', { FIREBASE_API_KEY: 'hostile-firebase-api-key' }, 'hostile-firebase-api-key'], + ['Firebase auth token', { FIREBASE_AUTH_TOKEN: 'hostile-firebase-auth-token' }, 'hostile-firebase-auth-token'], + ['old Firebase service account', { + FIREBASE_SERVICE_ACCOUNT_OLD: 'hostile-service-account', + }, 'hostile-service-account'], + ['production origin', { SITE_ORIGIN: 'https://runmprc.com' }, 'https://runmprc.com'], + ['deceptive test origin', { SITE_ORIGIN: 'https://example.test.attacker.invalid' }, 'example.test.attacker.invalid'], + ['live mode', { STRIPE_LIVEMODE_EXPECTED: 'true' }, 'true'], + ['live key', { STRIPE_SECRET_KEY: LIVE_KEY_CANARY }, LIVE_KEY_CANARY], + ['rotating live key', { STRIPE_SECRET_KEY_NEXT: LIVE_KEY_CANARY }, LIVE_KEY_CANARY], + ['rotating API key', { STRIPE_API_KEY_ROTATING: LIVE_KEY_CANARY }, LIVE_KEY_CANARY], + ['real-shaped webhook secret', { STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET_CANARY }, WEBHOOK_SECRET_CANARY], + ['old webhook secret', { + STRIPE_WEBHOOK_SECRET_OLD: WEBHOOK_SECRET_CANARY, + }, WEBHOOK_SECRET_CANARY], + ['external emulator', { FIRESTORE_EMULATOR_HOST: 'attacker.invalid:8080' }, 'attacker.invalid'], + ['unspecified emulator', { FIRESTORE_EMULATOR_HOST: '0.0.0.0:8080' }, '0.0.0.0'], + ['emulator without port', { FIRESTORE_EMULATOR_HOST: 'localhost' }, 'localhost'], + ['credential-bearing emulator', { FIRESTORE_EMULATOR_HOST: 'user@localhost:8080' }, 'user@localhost'], + ])('rejects %s with one redacted fixed error', (_name, environment, hostileValue) => { + expectSafeError( + () => assertSafeTestEnvironment(environment), + TEST_ENVIRONMENT_ERROR_MESSAGE, + hostileValue, + ); + }); + + test.each([ + ['null input', null], + ['proxy input', new Proxy({}, { + ownKeys() { + throw new Error('hostile-environment-canary'); + }, + })], + ['accessor input', Object.defineProperty({}, 'SITE_ORIGIN', { + enumerable: true, + get() { + throw new Error('hostile-environment-canary'); + }, + })], + ])('rejects %s without echoing accessor failures', (_name, environment) => { + expectSafeError( + () => assertSafeTestEnvironment(environment), + TEST_ENVIRONMENT_ERROR_MESSAGE, + 'hostile-environment-canary', + ); + }); +}); + +describe('process-wide network safety', () => { + test('installation is idempotent', () => { + const request = http.request; + const connect = net.connect; + const fetch = globalThis.fetch; + + expect(installNetworkGuard()).toBe(true); + expect(installNetworkGuard()).toBe(true); + expect(http.request).toBe(request); + expect(net.connect).toBe(connect); + expect(globalThis.fetch).toBe(fetch); + }); + + test('allows only exact loopback emulator host and port pairs', () => { + const environment = { + FIRESTORE_EMULATOR_HOST: '127.0.0.1:8080', + FIREBASE_AUTH_EMULATOR_HOST: '[::1]:9099', + }; + + expect(() => assertEndpointAllowed('127.0.0.1', 8080, environment)).not.toThrow(); + expect(() => assertEndpointAllowed('::1', 9099, environment)).not.toThrow(); + expectSafeError( + () => assertEndpointAllowed('localhost', 8080, environment), + NETWORK_ERROR_MESSAGE, + ); + expectSafeError( + () => assertEndpointAllowed('127.0.0.1', 8081, environment), + NETWORK_ERROR_MESSAGE, + ); + const valueOf = jest.fn(() => { + throw new Error('hostile-port-coercion-canary'); + }); + expectSafeError( + () => assertEndpointAllowed('127.0.0.1', { valueOf }, environment), + NETWORK_ERROR_MESSAGE, + 'hostile-port-coercion-canary', + ); + expect(valueOf).not.toHaveBeenCalled(); + }); + + test.each([ + ['HTTP request', () => http.request('http://external-network-canary.invalid/path')], + ['HTTP get', () => http.get('http://external-network-canary.invalid/path')], + ['HTTPS request', () => https.request('https://external-network-canary.invalid/path')], + ['HTTPS get', () => https.get('https://external-network-canary.invalid/path')], + ['HTTP/2', () => http2.connect('https://external-network-canary.invalid')], + ['TCP connect', () => net.connect(443, 'external-network-canary.invalid')], + ['TCP createConnection', () => net.createConnection(443, 'external-network-canary.invalid')], + ['TLS connect', () => tls.connect(443, 'external-network-canary.invalid')], + ['TLS port/options connect', () => tls.connect(443, { + host: 'external-network-canary.invalid', + })], + ['direct Socket connect', () => ( + new net.Socket().connect(443, 'external-network-canary.invalid') + )], + ['UDP send', () => { + const socket = dgram.createSocket('udp4'); + socket.send(Buffer.from('synthetic'), 53, 'external-network-canary.invalid'); + }], + ])('blocks %s before an outside connection with a redacted fixed error', (_name, connect) => { + expectSafeError(connect, NETWORK_ERROR_MESSAGE, 'external-network-canary.invalid'); + }); + + test('blocks fetch before an outside connection when fetch is available', () => { + if (typeof globalThis.fetch !== 'function') return; + expectSafeError( + () => globalThis.fetch('https://external-network-canary.invalid/path'), + NETWORK_ERROR_MESSAGE, + 'external-network-canary.invalid', + ); + }); + + test.each([ + ['lookup', (callback) => dns.lookup('external-network-canary.invalid', callback)], + ['resolve', (callback) => dns.resolve('external-network-canary.invalid', callback)], + ['reverse', (callback) => dns.reverse('203.0.113.10', callback)], + ['Resolver.resolve', (callback) => ( + new dns.Resolver().resolve('external-network-canary.invalid', callback) + )], + ['promises.lookup', () => dns.promises.lookup('external-network-canary.invalid')], + ['promises.resolve', () => dns.promises.resolve('external-network-canary.invalid')], + ['promises Resolver.resolve', () => ( + new dns.promises.Resolver().resolve('external-network-canary.invalid') + )], + ])('blocks DNS %s before resolver contact or callback', (_name, resolve) => { + const callback = jest.fn(); + expectSafeError( + () => resolve(callback), + NETWORK_ERROR_MESSAGE, + 'external-network-canary.invalid', + ); + expect(callback).not.toHaveBeenCalled(); + }); + + test.each([ + ['HTTP lookup', (callback) => http.request({ + host: '127.0.0.1', port: 54321, path: '/', lookup: callback, + })], + ['HTTP agent', (callback) => http.request({ + host: '127.0.0.1', port: 54321, path: '/', agent: { createConnection: callback }, + })], + ['direct ClientRequest agent', (callback) => new http.ClientRequest({ + host: '127.0.0.1', port: 54321, path: '/', agent: { addRequest: callback }, + })], + ['HTTP _defaultAgent', (callback) => http.request({ + host: '127.0.0.1', + port: 54321, + path: '/', + _defaultAgent: { createConnection: callback }, + })], + ['HTTPS createConnection', (callback) => https.request({ + host: '127.0.0.1', port: 54321, path: '/', createConnection: callback, + })], + ['HTTP socketPath', () => http.request({ + host: '127.0.0.1', port: 54321, path: '/', socketPath: '/hostile/socket', + })], + ['TCP lookup', (callback) => net.connect({ + host: '127.0.0.1', port: 54321, lookup: callback, + })], + ['TCP inherited lookup', (callback) => net.connect(Object.assign( + Object.create({ lookup: callback }), + { host: '127.0.0.1', port: 54321 }, + ))], + ['HTTP accessor lookup', (callback) => { + const options = { host: '127.0.0.1', port: 54321, path: '/' }; + Object.defineProperty(options, 'lookup', { get: callback }); + return http.request(options); + }], + ['direct Socket path', () => new net.Socket().connect({ path: '/hostile/socket' })], + ['TLS second-argument lookup', (callback) => tls.connect( + 54321, + { host: '127.0.0.1', lookup: callback }, + )], + ['TLS socket override', () => tls.connect({ + host: '127.0.0.1', port: 54321, socket: {}, + })], + ['UDP lookup', (callback) => dgram.createSocket({ type: 'udp4', lookup: callback })], + ])('rejects a safe declared host with a custom %s route before its callback', ( + _name, + attempt, + ) => { + const previous = process.env.TEST_SAFETY_EMULATOR_HOST; + process.env.TEST_SAFETY_EMULATOR_HOST = '127.0.0.1:54321'; + const callback = jest.fn(); + try { + expectSafeError(() => attempt(callback), NETWORK_ERROR_MESSAGE, '/hostile/socket'); + expect(callback).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) delete process.env.TEST_SAFETY_EMULATOR_HOST; + else process.env.TEST_SAFETY_EMULATOR_HOST = previous; + } + }); + + test('rejects and destroys an unmarked HTTP/2 factory socket before provider contact', () => { + const previous = process.env.TEST_SAFETY_EMULATOR_HOST; + process.env.TEST_SAFETY_EMULATOR_HOST = '127.0.0.1:54321'; + const socket = { destroy: jest.fn() }; + const createConnection = jest.fn(() => socket); + try { + expectSafeError( + () => http2.connect('http://127.0.0.1:54321', { createConnection }), + NETWORK_ERROR_MESSAGE, + ); + expect(createConnection).toHaveBeenCalledTimes(1); + expect(socket.destroy).toHaveBeenCalledTimes(1); + } finally { + if (previous === undefined) delete process.env.TEST_SAFETY_EMULATOR_HOST; + else process.env.TEST_SAFETY_EMULATOR_HOST = previous; + } + }); + + test('rejects a safe fetch target with a custom dispatcher before dispatch', () => { + if (typeof globalThis.fetch !== 'function') return; + const previous = process.env.TEST_SAFETY_EMULATOR_HOST; + process.env.TEST_SAFETY_EMULATOR_HOST = '127.0.0.1:54321'; + const dispatch = jest.fn(); + try { + expectSafeError( + () => globalThis.fetch('http://127.0.0.1:54321/', { + dispatcher: { dispatch }, + }), + NETWORK_ERROR_MESSAGE, + ); + expect(dispatch).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) delete process.env.TEST_SAFETY_EMULATOR_HOST; + else process.env.TEST_SAFETY_EMULATOR_HOST = previous; + } + }); + + test('rejects a routing hook inherited through Object.prototype pollution', () => { + const previous = process.env.TEST_SAFETY_EMULATOR_HOST; + const lookup = jest.fn(); + process.env.TEST_SAFETY_EMULATOR_HOST = '127.0.0.1:54321'; + Object.defineProperty(Object.prototype, 'lookup', { + configurable: true, + value: lookup, + }); + try { + expectSafeError( + () => net.connect({ host: '127.0.0.1', port: 54321 }), + NETWORK_ERROR_MESSAGE, + ); + expect(lookup).not.toHaveBeenCalled(); + } finally { + delete Object.prototype.lookup; + if (previous === undefined) delete process.env.TEST_SAFETY_EMULATOR_HOST; + else process.env.TEST_SAFETY_EMULATOR_HOST = previous; + } + }); + + test('resolves exact loopback names synthetically without outside DNS', async () => { + await expect(dns.promises.lookup('127.0.0.1')).resolves.toEqual({ + address: '127.0.0.1', + family: 4, + }); + await expect(dns.promises.lookup('localhost', { family: 6 })).resolves.toEqual({ + address: '::1', + family: 6, + }); + await expect(dns.promises.lookup('::1', { all: true })).resolves.toEqual([{ + address: '::1', + family: 6, + }]); + + await expect(new Promise((resolve, reject) => { + dns.lookup('localhost', (error, address, family) => { + if (error) reject(error); + else resolve({ address, family }); + }); + })).resolves.toEqual({ address: '127.0.0.1', family: 4 }); + }); + + test('allows an HTTP request whose declared emulator target defaults to localhost', async () => { + const server = http.createServer((_request, response) => response.end('synthetic-ok')); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + const previous = process.env.TEST_SAFETY_EMULATOR_HOST; + process.env.TEST_SAFETY_EMULATOR_HOST = `localhost:${port}`; + try { + const body = await new Promise((resolve, reject) => { + http.get({ port, path: '/' }, (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }).on('error', reject); + }); + expect(body).toBe('synthetic-ok'); + } finally { + if (previous === undefined) delete process.env.TEST_SAFETY_EMULATOR_HOST; + else process.env.TEST_SAFETY_EMULATOR_HOST = previous; + await new Promise((resolve) => server.close(resolve)); + } + }); + + test('allows a UDP connection to its exact declared loopback endpoint', async () => { + const server = dgram.createSocket('udp4'); + await new Promise((resolve) => server.bind(0, '127.0.0.1', resolve)); + const { port } = server.address(); + const previous = process.env.TEST_UDP_EMULATOR_HOST; + process.env.TEST_UDP_EMULATOR_HOST = `127.0.0.1:${port}`; + const client = dgram.createSocket('udp4'); + try { + await new Promise((resolve, reject) => { + client.once('error', reject); + client.connect(port, '127.0.0.1', resolve); + }); + expect(client.remoteAddress()).toMatchObject({ + address: '127.0.0.1', + family: 'IPv4', + port, + }); + } finally { + client.close(); + await new Promise((resolve) => server.close(resolve)); + if (previous === undefined) delete process.env.TEST_UDP_EMULATOR_HOST; + else process.env.TEST_UDP_EMULATOR_HOST = previous; + } + }); + +});