diff --git a/packages/php-wasm/universal/src/lib/php-request-handler.ts b/packages/php-wasm/universal/src/lib/php-request-handler.ts index 7b984ac8b2..207fedf616 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -15,7 +15,6 @@ import { PHPProcessManager, SpawnedPHP, } from './php-process-manager'; -import { HttpCookieStore } from './http-cookie-store'; import mimeTypes from './mime-types.json'; export type RewriteRule = { @@ -159,7 +158,6 @@ export class PHPRequestHandler { #HOST: string; #PATHNAME: string; #ABSOLUTE_URL: string; - #cookieStore: HttpCookieStore; rewriteRules: RewriteRule[]; processManager: PHPProcessManager; getFileNotFoundAction: FileNotFoundGetActionCallback; @@ -198,7 +196,6 @@ export class PHPRequestHandler { maxPhpInstances: config.maxPhpInstances, }); } - this.#cookieStore = new HttpCookieStore(); this.#DOCROOT = documentRoot; const url = new URL(absoluteUrl); @@ -490,7 +487,6 @@ export class PHPRequestHandler { const headers: Record = { host: this.#HOST, ...normalizeHeaders(request.headers || {}), - cookie: this.#cookieStore.getCookieRequestHeader(), }; let body = request.body; @@ -520,9 +516,6 @@ export class PHPRequestHandler { scriptPath, headers, }); - this.#cookieStore.rememberCookiesFromResponseHeaders( - response.headers - ); return response; } catch (error) { const executionError = error as PHPExecutionFailureError; diff --git a/packages/php-wasm/web-service-worker/src/utils.ts b/packages/php-wasm/web-service-worker/src/utils.ts index 8dc3256912..0723287111 100644 --- a/packages/php-wasm/web-service-worker/src/utils.ts +++ b/packages/php-wasm/web-service-worker/src/utils.ts @@ -43,6 +43,9 @@ export async function convertFetchEventToPHPRequest(event: FetchEvent) { 'User-agent': self.navigator.userAgent, 'Content-type': contentType, }, + // Relay credentials mode so the browser-based PHP worker + // can manage its own cookie store. + credentials: event.request.credentials, }, ], }; diff --git a/packages/playground/blueprints/src/lib/steps/enable-multisite.spec.ts b/packages/playground/blueprints/src/lib/steps/enable-multisite.spec.ts index 181251a434..9846ce978a 100644 --- a/packages/playground/blueprints/src/lib/steps/enable-multisite.spec.ts +++ b/packages/playground/blueprints/src/lib/steps/enable-multisite.spec.ts @@ -9,10 +9,15 @@ import { loadNodeRuntime } from '@php-wasm/node'; import { readFileSync } from 'fs'; import { join } from 'path'; import { login } from './login'; -import { PHPRequest, PHPRequestHandler } from '@php-wasm/universal'; +import { + HttpCookieStore, + PHPRequest, + PHPRequestHandler, +} from '@php-wasm/universal'; describe('Blueprint step enableMultisite', () => { let handler: PHPRequestHandler; + let cookieStore: HttpCookieStore; async function doBootWordPress(options: { absoluteUrl: string }) { handler = await bootWordPress({ createPhpRuntime: async () => @@ -28,16 +33,23 @@ describe('Blueprint step enableMultisite', () => { ), }, }); + cookieStore = new HttpCookieStore(); const php = await handler.getPrimaryPhp(); return { php, handler }; } - const requestFollowRedirects = async (request: PHPRequest) => { + const requestFollowRedirectsWithCookies = async (request: PHPRequest) => { let response = await handler.request(request); while (response.httpStatusCode === 302) { + cookieStore.rememberCookiesFromResponseHeaders(response.headers); + + const cookieHeader = cookieStore.getCookieRequestHeader(); response = await handler.request({ url: response.headers['location'][0], + headers: { + ...(cookieHeader && { cookie: cookieHeader }), + }, }); } return response; @@ -81,7 +93,7 @@ describe('Blueprint step enableMultisite', () => { * the admin bar includes the multisite menu. */ await login(php, {}); - const response = await requestFollowRedirects({ + const response = await requestFollowRedirectsWithCookies({ url: '/', }); expect(response.httpStatusCode).toEqual(200); diff --git a/packages/playground/blueprints/src/lib/steps/login.spec.ts b/packages/playground/blueprints/src/lib/steps/login.spec.ts index b3e47df284..83320c28dd 100644 --- a/packages/playground/blueprints/src/lib/steps/login.spec.ts +++ b/packages/playground/blueprints/src/lib/steps/login.spec.ts @@ -5,7 +5,7 @@ import { getWordPressModule, } from '@wp-playground/wordpress-builds'; import { login } from './login'; -import { PHPRequestHandler } from '@php-wasm/universal'; +import { PHPRequestHandler, HttpCookieStore } from '@php-wasm/universal'; import { bootWordPress } from '@wp-playground/wordpress'; import { loadNodeRuntime } from '@php-wasm/node'; import { defineWpConfigConsts } from './define-wp-config-consts'; @@ -14,6 +14,7 @@ import { joinPaths, phpVar } from '@php-wasm/util'; describe('Blueprint step login', () => { let php: PHP; let handler: PHPRequestHandler; + let cookieStore: HttpCookieStore; beforeEach(async () => { handler = await bootWordPress({ createPhpRuntime: async () => @@ -23,14 +24,21 @@ describe('Blueprint step login', () => { wordPressZip: await getWordPressModule(), sqliteIntegrationPluginZip: await getSqliteDatabaseModule(), }); + cookieStore = new HttpCookieStore(); php = await handler.getPrimaryPhp(); }); - const requestFollowRedirects = async (request: PHPRequest) => { + const requestFollowRedirectsWithCookies = async (request: PHPRequest) => { let response = await handler.request(request); while (response.httpStatusCode === 302) { + cookieStore.rememberCookiesFromResponseHeaders(response.headers); + + const cookieHeader = cookieStore.getCookieRequestHeader(); response = await handler.request({ url: response.headers['location'][0], + headers: { + ...(cookieHeader && { cookie: cookieHeader }), + }, }); } return response; @@ -38,7 +46,7 @@ describe('Blueprint step login', () => { it('should log the user in', async () => { await login(php, {}); - const response = await requestFollowRedirects({ + const response = await requestFollowRedirectsWithCookies({ url: '/', }); expect(response.httpStatusCode).toBe(200); @@ -47,7 +55,7 @@ describe('Blueprint step login', () => { it('should log the user into wp-admin', async () => { await login(php, {}); - const response = await requestFollowRedirects({ + const response = await requestFollowRedirectsWithCookies({ url: '/wp-admin/', }); expect(response.httpStatusCode).toBe(200); @@ -60,7 +68,7 @@ describe('Blueprint step login', () => { PLAYGROUND_FORCE_AUTO_LOGIN_ENABLED: true, }, }); - const response = await requestFollowRedirects({ + const response = await requestFollowRedirectsWithCookies({ url: '/?playground_force_auto_login_as_user=admin', }); expect(response.httpStatusCode).toBe(200); @@ -81,7 +89,7 @@ describe('Blueprint step login', () => { } ` ); - const response = await requestFollowRedirects({ + const response = await requestFollowRedirectsWithCookies({ url: '/nonce-test.php', }); expect(response.text).toBe('1'); diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index 8969854d9d..918b157418 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -38,6 +38,8 @@ import transportDummy from './playground-mu-plugin/playground-includes/wp_http_d /* @ts-ignore */ import playgroundWebMuPlugin from './playground-mu-plugin/0-playground.php?raw'; import { + HttpCookieStore, + PHPRequest, PHPResponse, PHPWorker, SupportedPHPVersion, @@ -78,7 +80,15 @@ export type WorkerBootOptions = { corsProxyUrl?: string; }; -/** @inheritDoc PHPClient */ +export interface PHPRequestWithCredentialsMode extends PHPRequest { + /** + * The fetch credentials mode to use for the request. + * Default: 'same-origin'. + */ + credentials?: RequestCredentials; +} + +/** @inheritDoc PHPWorker */ export class PlaygroundWorkerEndpoint extends PHPWorker { booted = false; @@ -99,6 +109,16 @@ export class PlaygroundWorkerEndpoint extends PHPWorker { unmounts: Record any> = {}; + /** + * A cookie store to remember cookies between requests. + * + * Web browsers don't permit relaying `Set-Cookie` headers + * via Response objects so the browser can store cookies from + * PHP responses. So we need to remember cookies ourselves. + * Ref: https://fetch.spec.whatwg.org/#forbidden-response-header-name + */ + #cookieStore: HttpCookieStore = new HttpCookieStore(); + constructor(monitor: EmscriptenDownloadMonitor) { super(undefined, monitor); } @@ -448,6 +468,56 @@ export class PlaygroundWorkerEndpoint extends PHPWorker { } } + /** @inheritDoc @php-wasm/universal!PHPRequestHandler.request */ + override async request( + request: PHPRequestWithCredentialsMode + ): Promise { + const credentialsMode: RequestCredentials = + // Default to same-origin. + // https://fetch.spec.whatwg.org/#concept-request-credentials-mode + request.credentials ?? 'same-origin'; + const credentialsAllowed = + credentialsMode === 'include' || credentialsMode === 'same-origin'; + + const incomingHeaders = request.headers ?? {}; + const headers: Record = {}; + let incomingCookies = ''; + for (const [name, value] of Object.entries(incomingHeaders)) { + if (name.toLowerCase() === 'cookie') { + incomingCookies = value; + } else { + headers[name] = value; + } + } + + if (credentialsAllowed) { + const storedCookies = this.#cookieStore.getCookieRequestHeader(); + const cookieSegments = []; + storedCookies && cookieSegments.push(storedCookies); + incomingCookies && cookieSegments.push(incomingCookies); + const cookieHeader = cookieSegments.join('; '); + + if (cookieHeader) { + headers['cookie'] = cookieHeader; + } + } + + const phpResponse = await super.request({ + ...request, + headers, + }); + + // Paraphrased from https://fetch.spec.whatwg.org/#http-network-fetch: + // > If `includeCredentials` is true, then apply set-cookie headers. + if (credentialsAllowed) { + this.#cookieStore.rememberCookiesFromResponseHeaders( + phpResponse.headers + ); + } + + return phpResponse; + } + // These methods are only here for the time traveling Playground demo. // Let's consider removing them in the future.