diff --git a/lib/browser/api/protocol.ts b/lib/browser/api/protocol.ts index 3d5f7cd96b632..0bdec72983327 100644 --- a/lib/browser/api/protocol.ts +++ b/lib/browser/api/protocol.ts @@ -29,6 +29,21 @@ function makeStreamFromPipe (pipe: any): ReadableStream { }); } +function makeStreamFromFileInfo ({ + filePath, + offset = 0, + length = -1 +}: { + filePath: string; + offset?: number; + length?: number; +}): ReadableStream { + return Readable.toWeb(createReadStream(filePath, { + start: offset, + end: length >= 0 ? offset + length : undefined + })); +} + function convertToRequestBody (uploadData: ProtocolRequest['uploadData']): RequestInit['body'] { if (!uploadData) return null; // Optimization: skip creating a stream if the request is just a single buffer. @@ -37,30 +52,42 @@ function convertToRequestBody (uploadData: ProtocolRequest['uploadData']): Reque const chunks = [...uploadData] as any[]; // TODO: types are wrong let current: ReadableStreamDefaultReader | null = null; return new ReadableStream({ - pull (controller) { + async pull (controller) { if (current) { - current.read().then(({ done, value }) => { + const { done, value } = await current.read(); + // (done => value === undefined) as per WHATWG spec + if (done) { + current = null; + return this.pull!(controller); + } else { controller.enqueue(value); - if (done) current = null; - }, (err) => { - controller.error(err); - }); + } } else { if (!chunks.length) { return controller.close(); } const chunk = chunks.shift()!; - if (chunk.type === 'rawData') { controller.enqueue(chunk.bytes); } else if (chunk.type === 'file') { - current = Readable.toWeb(createReadStream(chunk.filePath, { start: chunk.offset ?? 0, end: chunk.length >= 0 ? chunk.offset + chunk.length : undefined })).getReader(); - this.pull!(controller); + if (chunk.type === 'rawData') { + controller.enqueue(chunk.bytes); + } else if (chunk.type === 'file') { + current = makeStreamFromFileInfo(chunk).getReader(); + return this.pull!(controller); } else if (chunk.type === 'stream') { current = makeStreamFromPipe(chunk.body).getReader(); - this.pull!(controller); + return this.pull!(controller); + } else if (chunk.type === 'blob') { + // Note that even though `getBlobData()` is a `Session` API, it doesn't + // actually use the `Session` context. Its implementation solely relies + // on global variables which allows us to implement this feature without + // knowledge of the `Session` associated with the current request by + // always pulling `Blob` data out of the default `Session`. + controller.enqueue(await session.defaultSession.getBlobData(chunk.blobUUID)); + } else { + throw new Error(`Unknown upload data chunk type: ${chunk.type}`); } } } }) as RequestInit['body']; } -// TODO(codebytere): Use Object.hasOwn() once we update to ECMAScript 2022. function validateResponse (res: Response) { if (!res || typeof res !== 'object') return false; @@ -85,8 +112,12 @@ Protocol.prototype.handle = function (this: Electron.Protocol, scheme: string, h const success = register.call(this, scheme, async (preq: ProtocolRequest, cb: any) => { try { const body = convertToRequestBody(preq.uploadData); + const headers = new Headers(preq.headers); + if (headers.get('origin') === 'null') { + headers.delete('origin'); + } const req = new Request(preq.url, { - headers: preq.headers, + headers, method: preq.method, referrer: preq.referrer, body, diff --git a/spec/api-protocol-spec.ts b/spec/api-protocol-spec.ts index 19ba61de12b8d..348f6c48e6b76 100644 --- a/spec/api-protocol-spec.ts +++ b/spec/api-protocol-spec.ts @@ -8,6 +8,8 @@ import * as http from 'node:http'; import * as fs from 'node:fs'; import * as qs from 'node:querystring'; import * as stream from 'node:stream'; +import * as streamConsumers from 'node:stream/consumers'; +import * as webStream from 'node:stream/web'; import { EventEmitter, once } from 'node:events'; import { closeAllWindows, closeWindow } from './lib/window-helpers'; import { WebmGenerator } from './lib/video-helpers'; @@ -1548,6 +1550,122 @@ describe('protocol module', () => { } }); + it('does not emit undefined chunks into the request body stream when uploading a stream', async () => { + protocol.handle('cors', async (request) => { + expect(request.body).to.be.an.instanceOf(webStream.ReadableStream); + for await (const value of request.body as webStream.ReadableStream) { + expect(value).to.not.be.undefined(); + } + return new Response(undefined, { status: 200 }); + }); + defer(() => { protocol.unhandle('cors'); }); + + await contents.loadFile(path.resolve(fixturesPath, 'pages', 'base-page.html')); + contents.on('console-message', (e, level, message) => console.log(message)); + const ok = await contents.executeJavaScript(`(async () => { + function wait(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + + const stream = new ReadableStream({ + async start(controller) { + await wait(4); + controller.enqueue('This '); + await wait(4); + controller.enqueue('is '); + await wait(4); + controller.enqueue('a '); + await wait(4); + controller.enqueue('slow '); + await wait(4); + controller.enqueue('request.'); + controller.close(); + } + }).pipeThrough(new TextEncoderStream()); + return (await fetch('cors://url.invalid', { method: 'POST', body: stream, duplex: 'half' })).ok; + })()`); + expect(ok).to.be.true(); + }); + + it('does not emit undefined chunks into the request body stream when uploading a file', async () => { + protocol.handle('cors', async (request) => { + expect(request.body).to.be.an.instanceOf(webStream.ReadableStream); + for await (const value of request.body as webStream.ReadableStream) { + expect(value).to.not.be.undefined(); + } + return new Response(undefined, { status: 200 }); + }); + defer(() => { protocol.unhandle('cors'); }); + + await contents.loadFile(path.resolve(fixturesPath, 'pages', 'file-input.html')); + const { debugger: debug } = contents; + debug.attach(); + try { + const { root: { nodeId } } = await debug.sendCommand('DOM.getDocument'); + const { nodeId: inputNodeId } = await debug.sendCommand('DOM.querySelector', { nodeId, selector: 'input' }); + await debug.sendCommand('DOM.setFileInputFiles', { + files: [path.join(fixturesPath, 'cat-spin.mp4')], + nodeId: inputNodeId + }); + const ok = await contents.executeJavaScript(`(async () => { + const formData = new FormData(); + formData.append("data", document.getElementById("file").files[0]); + return (await fetch('cors://url.invalid', { method: 'POST', body: formData })).ok; + })()`); + expect(ok).to.be.true(); + } finally { + debug.detach(); + } + }); + + it('filters an illegal "origin: null" header', async () => { + protocol.handle('http', (req) => { + expect(new Headers(req.headers).get('origin')).to.not.equal('null'); + return new Response(); + }); + defer(() => { protocol.unhandle('http'); }); + + const filePath = path.join(fixturesPath, 'pages', 'form-with-data.html'); + await contents.loadFile(filePath); + + const loadPromise = new Promise((resolve, reject) => { + contents.once('did-finish-load', resolve); + contents.once('did-fail-load', (_, errorCode, errorDescription) => + reject(new Error(`did-fail-load: ${errorCode} ${errorDescription}. See AssertionError for details.`)) + ); + }); + await contents.executeJavaScript(` + const form = document.querySelector('form'); + form.action = 'http://cors.invalid'; + form.method = 'POST'; + form.submit(); + `); + await loadPromise; + }); + + it('does forward Blob chunks', async () => { + // we register the protocol on a separate session to validate the assumption + // that `getBlobData()` indeed returns the blob data from a global variable + const s = session.fromPartition('protocol-handle-forwards-blob-chunks'); + + s.protocol.handle('cors', async (request) => { + expect(request.body).to.be.an.instanceOf(webStream.ReadableStream); + return new Response( + `hello to ${await streamConsumers.text(request.body as webStream.ReadableStream)}`, + { status: 200 } + ); + }); + defer(() => { s.protocol.unhandle('cors'); }); + + const w = new BrowserWindow({ show: false, webPreferences: { session: s } }); + await w.webContents.loadFile(path.resolve(fixturesPath, 'pages', 'base-page.html')); + const response = await w.webContents.executeJavaScript(`(async () => { + const body = new Blob(["it's-a ", 'me! ', 'Mario!'], { type: 'text/plain' }); + return await (await fetch('cors://url.invalid', { method: 'POST', body })).text(); + })()`); + expect(response).to.be.string('hello to it\'s-a me! Mario!'); + }); + // TODO(nornagon): this test doesn't pass on Linux currently, investigate. ifit(process.platform !== 'linux')('is fast', async () => { // 128 MB of spaces.