diff --git a/.github/workflows/cloudflareWorkerTests.yml b/.github/workflows/cloudflareWorkerTests.yml index aff7d9d745ed..41718e588f1c 100644 --- a/.github/workflows/cloudflareWorkerTests.yml +++ b/.github/workflows/cloudflareWorkerTests.yml @@ -10,6 +10,7 @@ on: - 'web/index.html' - '.nvmrc' - '.github/workflows/cloudflareWorkerTests.yml' + - '.github/workflows/deployPRTestingWorker.yml' concurrency: group: ${{ github.ref }}-cloudflare-worker-tests diff --git a/.github/workflows/deployPRTestingWorker.yml b/.github/workflows/deployPRTestingWorker.yml new file mode 100644 index 000000000000..d7350ce98ef4 --- /dev/null +++ b/.github/workflows/deployPRTestingWorker.yml @@ -0,0 +1,69 @@ +name: Deploy PR-testing Cloudflare Worker + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-pr-testing-worker + cancel-in-progress: false + +jobs: + validateActor: + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + outputs: + IS_DEPLOYER: ${{ steps.isUserDeployer.outputs.IS_DEPLOYER }} + steps: + - id: isUserDeployer + run: | + if gh api "/orgs/Expensify/teams/web-deployers/memberships/$GITHUB_ACTOR" --silent; then + echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" + elif gh api "/orgs/Expensify/teams/keyholders/memberships/$GITHUB_ACTOR" --silent; then + echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" + elif gh api "/orgs/Expensify/teams/mobile-deployers/memberships/$GITHUB_ACTOR" --silent; then + echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" + else + echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" + exit 1 + fi + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} + + deploy: + needs: validateActor + if: ${{ needs.validateActor.outputs.IS_DEPLOYER == 'true' }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + defaults: + run: + working-directory: web/cloudflare-worker + env: + WRANGLER_SEND_METRICS: false + steps: + - name: Checkout + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + + - name: Setup Node + # v6.1.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: web/cloudflare-worker/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test + + - name: Deploy the existing PR-testing Worker + run: npx wrangler deploy --config wrangler.pr-testing.jsonc + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index 4494e101aa7d..a3ac3a89ce53 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -178,3 +178,31 @@ npx wrangler deploy --dry-run --env test-staging ``` Never commit credentials, account IDs or `.dev.vars` files here. This is a public repository. + +## PR-testing Worker + +[`src/prTesting.ts`](src/prTesting.ts) serves `https://.pr-testing.expensify.com` from the existing public S3 prefix `https://ad-hoc-expensify-cash.s3.amazonaws.com/web//`. It shares the security headers and CSP definitions with the static-assets Worker, with PR-testing font and frame origins. It also allows the App template's inline script hashes. + +This is one wildcard Worker, not a Worker per PR. Ad-hoc builds still upload to S3 in `.github/workflows/buildAdHoc.yml`; no build or hosting changes are needed. The existing non-200-to-`index.html` fallback is preserved. `/`, `/index.html`, `/service-worker.js` and fallback HTML bypass forced edge caching. HTML also drops validators and gets `Cache-Control: no-cache` so each rewritten nonce matches its CSP. + +[`wrangler.pr-testing.jsonc`](wrangler.pr-testing.jsonc) is deliberately separate from the static-assets config: PR testing needs neither `dist/` nor an `ASSETS` or `PREVIOUS_BUILDS` binding. It retains the existing Worker name (`pr-testing-expensify`) and compatibility date. Its existing `*.pr-testing.expensify.com/*` route stays managed in Cloudflare, so deploys need no zone access. + +### Migration and deployment + +1. Grant `CLOUDFLARE_WORKERS_DEPLOY_TOKEN` access to edit the existing `pr-testing-expensify` Worker, in addition to the staging and production Workers. Keep `CLOUDFLARE_ACCOUNT_ID` in GitHub secrets. +2. Merge the App PR, then manually run **Deploy PR-testing Cloudflare Worker** (`.github/workflows/deployPRTestingWorker.yml`) on `main`. It requires membership in the web deployer, key holder or mobile deployer team, matching the old repository's deploy authorization. It runs the package's type check and tests before deploying. +3. Verify an existing PR hostname, a deep link, its service worker and two different PR builds. Rebuild one PR and check that the new build appears without stale HTML or CSP errors. +4. Only after that deployment is verified, merge the companion Cloudflare-Workers cleanup PR. Do not delete the live Worker or its wildcard route. If Workers Builds is connected to the old repository, disconnect that integration during the handoff so it cannot deploy the old code again. + +The workflow is manual, like the old repository's deploy workflow; it does not redeploy this shared Worker for every ad-hoc build. A rollback before cleanup can redeploy the previous commit from Cloudflare-Workers. After cleanup, use a previous Worker version in Cloudflare to roll back. + +Local verification, without credentials or uploading anything: + +```bash +npm ci +npm run typecheck +npm test +npx wrangler deploy --dry-run --config wrangler.pr-testing.jsonc +``` + +The PR-testing tests bundle the real configuration and run it in workerd with all requests to S3 intercepted. They verify PR isolation, edge-cache options, SPA fallbacks, nonce matching, shared security headers and service-worker caching without reading live builds. diff --git a/web/cloudflare-worker/package-lock.json b/web/cloudflare-worker/package-lock.json index c77eaf3468cc..da7d46313734 100644 --- a/web/cloudflare-worker/package-lock.json +++ b/web/cloudflare-worker/package-lock.json @@ -10,6 +10,7 @@ "devDependencies": { "@types/node": "24.19.0", "@types/ws": "8.18.1", + "miniflare": "5.20261019.2-beta", "typescript": "7.0.2", "wrangler": "4.152.0", "ws": "8.21.0" diff --git a/web/cloudflare-worker/package.json b/web/cloudflare-worker/package.json index d56e92d0554d..5913706b192e 100644 --- a/web/cloudflare-worker/package.json +++ b/web/cloudflare-worker/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@types/node": "24.19.0", "@types/ws": "8.18.1", + "miniflare": "5.20261019.2-beta", "typescript": "7.0.2", "wrangler": "4.152.0", "ws": "8.21.0" diff --git a/web/cloudflare-worker/src/csp.ts b/web/cloudflare-worker/src/csp.ts index d418c1ff00f4..c02e280aa437 100644 --- a/web/cloudflare-worker/src/csp.ts +++ b/web/cloudflare-worker/src/csp.ts @@ -184,8 +184,16 @@ const PRODUCTION_CSP = buildCSP({ scriptSrcHashes: INLINE_SCRIPT_HASHES, }); -function getCSP(environment: Env['ENVIRONMENT'] | DevelopmentEnv['ENVIRONMENT'], nonce: string, developmentOrigins?: DevelopmentOrigins): string { - let csp = environment === 'staging' ? STAGING_CSP : PRODUCTION_CSP; +const PR_TESTING_CSP = buildCSP({ + fontSrcHosts: ['https://*.pr-testing.expensify.com'], + frameSrcHosts: ['https://staging.expensify.com'], + scriptSrcHashes: INLINE_SCRIPT_HASHES, +}); + +type Environment = Env['ENVIRONMENT'] | DevelopmentEnv['ENVIRONMENT'] | 'pr-testing'; + +function getCSP(environment: Environment, nonce: string, developmentOrigins?: DevelopmentOrigins): string { + let csp = environment === 'pr-testing' ? PR_TESTING_CSP : environment === 'staging' ? STAGING_CSP : PRODUCTION_CSP; if (environment === 'development') { const backendOrigins = [...new Set([DEVELOPMENT_OLDDOT_ORIGIN, ...(developmentOrigins?.backendOrigins ?? [])])]; csp = buildCSP({ @@ -201,4 +209,4 @@ function getCSP(environment: Env['ENVIRONMENT'] | DevelopmentEnv['ENVIRONMENT'], } export {NONCE_PLACEHOLDER, getCSP}; -export type {DevelopmentOrigins}; +export type {DevelopmentOrigins, Environment}; diff --git a/web/cloudflare-worker/src/index.ts b/web/cloudflare-worker/src/index.ts index f718917bf461..afe7ab6cbbdc 100644 --- a/web/cloudflare-worker/src/index.ts +++ b/web/cloudflare-worker/src/index.ts @@ -1,9 +1,10 @@ import type {DevelopmentOrigins} from './csp'; -import {getCSP, NONCE_PLACEHOLDER} from './csp'; +import {NONCE_PLACEHOLDER} from './csp'; import {isAPIRequest} from './developmentAPIProxy'; import getDevelopmentConfig from './developmentConfig'; import fetchDevelopmentResponse from './fetchDevelopmentResponse'; +import {generateNonce, setSecurityHeaders} from './security'; /** Content-hashed build output (`main-<16 hex>.bundle.js`, `main.<10 hex>.css`, ...), emitted at the root of `dist/`. */ const HASHED_ASSET_PATH = /^\/[^/]+(?:-[0-9a-f]{16}\.bundle\.js|\.[0-9a-f]{10}\.[a-z0-9]+)$/; @@ -27,12 +28,6 @@ const REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate'; const AASA_PATH = '/.well-known/apple-app-site-association'; const LEGACY_AASA_PATH = '/apple-app-site-association'; -function generateNonce(): string { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return btoa(String.fromCharCode(...bytes)); -} - function isHTMLResponse(response: Response): boolean { return response.headers.get('Content-Type')?.startsWith('text/html') ?? false; } @@ -89,16 +84,6 @@ function buildAssetRequest(request: Request, url: URL, pathSuffix = ''): Request return assetRequest; } -function setSecurityHeaders(headers: Headers, environment: Env['ENVIRONMENT'] | DevelopmentEnv['ENVIRONMENT'], nonce: string, developmentOrigins?: DevelopmentOrigins) { - headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); - headers.set('Content-Security-Policy', getCSP(environment, nonce, developmentOrigins)); - headers.set('X-Frame-Options', 'SAMEORIGIN'); - headers.set('X-Content-Type-Options', 'nosniff'); - headers.set('Referrer-Policy', 'origin-when-cross-origin'); - // Used by react-native-release-profiler - headers.set('Document-Policy', 'js-profiling'); -} - function createNotFoundResponse(environment: Env['ENVIRONMENT'] | DevelopmentEnv['ENVIRONMENT'], nonce: string, developmentOrigins?: DevelopmentOrigins): Response { const notFound = new Response('Not Found', {status: 404, headers: {'Content-Type': 'text/plain; charset=utf-8'}}); setSecurityHeaders(notFound.headers, environment, nonce, developmentOrigins); diff --git a/web/cloudflare-worker/src/prTesting.ts b/web/cloudflare-worker/src/prTesting.ts new file mode 100644 index 000000000000..61e8bfdd93ac --- /dev/null +++ b/web/cloudflare-worker/src/prTesting.ts @@ -0,0 +1,45 @@ +/** Serves each PR's existing S3 build through the shared wildcard PR-testing Worker. */ +import {NONCE_PLACEHOLDER} from './csp'; +import {generateNonce, setSecurityHeaders} from './security'; + +// These URLs are overwritten by every ad-hoc build, unlike the content-hashed assets. +const UNVERSIONED_PATHS = new Set(['/', '/index.html', '/service-worker.js']); + +export default { + async fetch(request): Promise { + const url = new URL(request.url); + const prNumber = url.hostname.split('.')[0]; + const bucketURL = `https://ad-hoc-expensify-cash.s3.amazonaws.com/web/${prNumber}`; + const assetURL = bucketURL + (url.pathname === '/' ? '/index.html' : url.pathname); + let assetResponse = await fetch(assetURL, { + cf: {cacheEverything: !UNVERSIONED_PATHS.has(url.pathname)}, + }); + + // Preserve the existing SPA fallback, including for missing assets, during the repository migration. + if (assetResponse.status !== 200) { + assetResponse = await fetch(`${bucketURL}/index.html`, { + cf: {cacheEverything: false}, + }); + } + + const nonce = generateNonce(); + const response = new Response(assetResponse.body, assetResponse); + setSecurityHeaders(response.headers, 'pr-testing', nonce); + + if (!response.headers.get('Content-Type')?.startsWith('text/html')) { + return response; + } + + // A cached HTML body must not be paired with a freshly generated CSP nonce. + response.headers.set('Cache-Control', 'no-cache'); + response.headers.delete('ETag'); + response.headers.delete('Last-Modified'); + return new HTMLRewriter() + .on(`script[nonce="${NONCE_PLACEHOLDER}"]`, { + element(element) { + element.setAttribute('nonce', nonce); + }, + }) + .transform(response); + }, +} satisfies ExportedHandler; diff --git a/web/cloudflare-worker/src/security.ts b/web/cloudflare-worker/src/security.ts new file mode 100644 index 000000000000..f7e1b75f5c8e --- /dev/null +++ b/web/cloudflare-worker/src/security.ts @@ -0,0 +1,22 @@ +import type {DevelopmentOrigins, Environment} from './csp'; + +/** Security headers and nonce generation shared by the static-assets and PR-testing Workers. */ +import {getCSP} from './csp'; + +function generateNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return btoa(String.fromCharCode(...bytes)); +} + +function setSecurityHeaders(headers: Headers, environment: Environment, nonce: string, developmentOrigins?: DevelopmentOrigins) { + headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + headers.set('Content-Security-Policy', getCSP(environment, nonce, developmentOrigins)); + headers.set('X-Frame-Options', 'SAMEORIGIN'); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Referrer-Policy', 'origin-when-cross-origin'); + // Used by react-native-release-profiler + headers.set('Document-Policy', 'js-profiling'); +} + +export {generateNonce, setSecurityHeaders}; diff --git a/web/cloudflare-worker/tests/development.test.ts b/web/cloudflare-worker/tests/development.test.ts index 25d8fb3219ab..bef92b11c269 100644 --- a/web/cloudflare-worker/tests/development.test.ts +++ b/web/cloudflare-worker/tests/development.test.ts @@ -259,7 +259,11 @@ describe('live development worker', () => { // When the entry point and an imported module change, without restarting Wrangler writeFileSync( entryPath, - replaceOnce(entry, "headers.set('X-Content-Type-Options', 'nosniff');", "headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Reload-Proof', 'entry');"), + replaceOnce( + entry, + 'setSecurityHeaders(headers, env.ENVIRONMENT, nonce, developmentOrigins);', + "setSecurityHeaders(headers, env.ENVIRONMENT, nonce, developmentOrigins); headers.set('X-Reload-Proof', 'entry');", + ), ); await waitForResponse((response) => response.headers.get('X-Reload-Proof') === 'entry'); writeFileSync(cspPath, replaceOnce(csp, "'worker-src': [", "'worker-src': ['https://module-reload.invalid', ")); diff --git a/web/cloudflare-worker/tests/prTesting.test.ts b/web/cloudflare-worker/tests/prTesting.test.ts new file mode 100644 index 000000000000..9612242203ba --- /dev/null +++ b/web/cloudflare-worker/tests/prTesting.test.ts @@ -0,0 +1,216 @@ +import {Miniflare, Request as WorkerRequest, Response as WorkerResponse} from 'miniflare'; +/** Runs the PR-testing Worker in workerd with S3 requests mocked, never reaching a real PR build. */ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {after, before, describe, test} from 'node:test'; +import {experimental_readRawConfig} from 'wrangler'; + +const WORKER_DIR = path.resolve(import.meta.dirname, '..'); +const HTML = ''; + +describe('PR-testing worker', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'pr-testing-worker-')); + const pending: Array<{ + pathname: string; + status: number; + body: string; + headers: Record; + }> = []; + const requests: Array<{pathname: string; cacheEverything: unknown}> = []; + let worker: Miniflare; + + function mockS3(pathname: string, status: number, body: string, headers: Record = {}) { + pending.push({pathname, status, body, headers}); + } + + before(async () => { + const configPath = path.join(WORKER_DIR, 'wrangler.pr-testing.jsonc'); + execFileSync(process.execPath, [path.join(WORKER_DIR, 'node_modules/wrangler/bin/wrangler.js'), 'deploy', '--dry-run', '--config', configPath, '--outdir', tempDir], { + env: {...process.env, WRANGLER_SEND_METRICS: 'false'}, + }); + const {rawConfig} = experimental_readRawConfig({config: configPath}); + worker = new Miniflare({ + workers: [ + { + config: { + name: rawConfig.name ?? '', + compatibilityDate: rawConfig.compatibility_date ?? '', + manifest: { + mainModule: 'prTesting.js', + modules: { + 'prTesting.js': { + type: 'esm', + contents: readFileSync(path.join(tempDir, 'prTesting.js'), 'utf8'), + }, + }, + }, + }, + dev: { + outboundService: { + type: 'fetcher', + handler(request: WorkerRequest) { + const url = new URL(request.url); + assert.equal(url.origin, 'https://ad-hoc-expensify-cash.s3.amazonaws.com'); + const expected = pending.shift(); + assert.ok(expected, `Unexpected S3 request: ${url.pathname}`); + assert.equal(url.pathname, expected.pathname); + requests.push({ + pathname: url.pathname, + cacheEverything: request.cf?.cacheEverything, + }); + return new WorkerResponse(expected.body, { + status: expected.status, + headers: expected.headers, + }); + }, + }, + }, + }, + ], + }); + await worker.ready; + }); + + after(async () => { + await worker?.dispose(); + rmSync(tempDir, {recursive: true, force: true}); + assert.equal(pending.length, 0, 'Every expected S3 request must be made'); + }); + + function mockHTML(prNumber: string) { + mockS3(`/web/${prNumber}/index.html`, 200, HTML, { + 'Content-Type': 'text/html', + ETag: '"old-shell"', + 'Last-Modified': 'Wed, 01 Jan 2025 00:00:00 GMT', + }); + } + + test('serves the S3 directory selected by the PR hostname', async () => { + // Given two PR builds with different assets at the same path + for (const prNumber of ['12345', '67890']) { + mockS3(`/web/${prNumber}/bundle.js`, 200, `// PR ${prNumber}`, { + 'Content-Type': 'text/javascript', + 'Cache-Control': 'public,max-age=31536000,immutable', + }); + + // When each PR's hostname requests that asset + const response = await worker.dispatchFetch(`https://${prNumber}.pr-testing.expensify.com/bundle.js`); + + // Then it gets its own build and retains the origin's asset caching policy + assert.equal(response.status, 200); + assert.equal(await response.text(), `// PR ${prNumber}`); + assert.equal(response.headers.get('Cache-Control'), 'public,max-age=31536000,immutable'); + assert.equal(response.headers.get('X-Content-Type-Options'), 'nosniff'); + assert.equal(requests.at(-1)?.cacheEverything, true); + } + }); + + test('serves the root and index.html with matching fresh CSP nonces', async () => { + // Given two loads of the same PR's app shell + const nonces = new Set(); + for (const pathname of ['/', '/index.html']) { + mockHTML('12345'); + + // When the shell is requested at either URL + const response = await worker.dispatchFetch(`https://12345.pr-testing.expensify.com${pathname}`); + const html = await response.text(); + const nonce = /