/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; const { DefaultMap } = ExtensionUtils; const lazy = {}; XPCOMUtils.defineLazyServiceGetter( lazy, "mimeHeader", "@mozilla.org/network/mime-hdrparam;1", "nsIMIMEHeaderParam" ); const BinaryInputStream = Components.Constructor( "@mozilla.org/binaryinputstream;1", "nsIBinaryInputStream", "setInputStream" ); const ConverterInputStream = Components.Constructor( "@mozilla.org/intl/converter-input-stream;1", "nsIConverterInputStream", "init" ); export var WebRequestUpload; /** * Parses the given raw header block, and stores the value of each * lower-cased header name in the resulting map. */ class Headers extends Map { constructor(headerText) { super(); if (headerText) { this.parseHeaders(headerText); } } parseHeaders(headerText) { let lines = headerText.split("\r\n"); let lastHeader; for (let line of lines) { // The first empty line indicates the end of the header block. if (line === "") { return; } // Lines starting with whitespace are appended to the previous // header. if (/^\s/.test(line)) { if (lastHeader) { let val = this.get(lastHeader); this.set(lastHeader, `${val}\r\n${line}`); } continue; } let match = /^(.*?)\s*:\s+(.*)/.exec(line); if (match) { lastHeader = match[1].toLowerCase(); this.set(lastHeader, match[2]); } } } /** * If the given header exists, and contains the given parameter, * returns the value of that parameter. * * @param {string} name * The lower-cased header name. * @param {string} paramName * The name of the parameter to retrieve, or empty to retrieve * the first (possibly unnamed) parameter. * @returns {string | null} */ getParam(name, paramName) { return Headers.getParam(this.get(name), paramName); } /** * If the given header value is non-null, and contains the given * parameter, returns the value of that parameter. * * @param {string | null} header * The text of the header from which to retrieve the param. * @param {string} paramName * The name of the parameter to retrieve, or empty to retrieve * the first (possibly unnamed) parameter. * @returns {string | null} */ static getParam(header, paramName) { if (header) { // The service expects this to be a raw byte string, so convert to // UTF-8. let bytes = new TextEncoder().encode(header); let binHeader = String.fromCharCode(...bytes); return lazy.mimeHeader.getParameterHTTP( binHeader, paramName, null, false, {} ); } return null; } } /** * Creates a new Object with a corresponding property for every * key-value pair in the given Map. * * @param {Map} map * The map to convert. * @returns {object} */ function mapToObject(map) { let result = {}; for (let [key, value] of map) { result[key] = value; } return result; } /** * Rewinds the given seekable input stream to its beginning, and catches * any resulting errors. * * @param {nsISeekableStream} stream * The stream to rewind. */ function rewind(stream) { // Do this outside the try-catch so that we throw if the stream is not // actually seekable. stream.QueryInterface(Ci.nsISeekableStream); try { stream.seek(0, 0); } catch (e) { // It might be already closed, e.g. because of a previous error. Cu.reportError(e); } } /** * Iterates over all of the sub-streams that make up the given stream, * or yields the stream itself if it is not a multi-part stream. * * @param {nsIIMultiplexInputStream|nsIStreamBufferAccess|nsIInputStream} outerStream * The outer stream over which to iterate. */ function* getStreams(outerStream) { // If this is a multi-part stream, we need to iterate over its sub-streams, // rather than treating it as a simple input stream. Since it may be wrapped // in a buffered input stream, unwrap it before we do any checks. let unbuffered = outerStream; if (outerStream instanceof Ci.nsIStreamBufferAccess) { unbuffered = outerStream.unbufferedStream; } if (unbuffered instanceof Ci.nsIMultiplexInputStream) { let count = unbuffered.count; for (let i = 0; i < count; i++) { yield unbuffered.getStream(i); } } else { yield outerStream; } } /** * Parses the form data of the given stream as either multipart/form-data or * x-www-form-urlencoded, and returns a map of its fields. * * @param {nsIInputStream} stream * The input stream from which to parse the form data. * @param {nsIHttpChannel} channel * The channel to which the stream belongs. * @param {boolean} [lenient = false] * If true, the operation will succeed even if there are UTF-8 * decoding errors. * * @returns {Map> | null} */ function parseFormData(stream, channel, lenient = false) { const BUFFER_SIZE = 8192; let touchedStreams = new Set(); let converterStreams = []; /** * Creates a converter input stream from the given raw input stream, * and adds it to the list of streams to be rewound at the end of * parsing. * * Returns null if the given raw stream cannot be rewound. * * @param {nsIInputStream} stream * The base stream from which to create a converter. * @returns {ConverterInputStream | null} */ function createTextStream(stream) { if (!(stream instanceof Ci.nsISeekableStream)) { return null; } touchedStreams.add(stream); let converterStream = ConverterInputStream( stream, "UTF-8", 0, lenient ? Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER : 0 ); converterStreams.push(converterStream); return converterStream; } /** * Reads a string of no more than the given length from the given text * stream. * * @param {ConverterInputStream} stream * The stream to read. * @param {integer} [length = BUFFER_SIZE] * The maximum length of data to read. * @returns {string} */ function readString(stream, length = BUFFER_SIZE) { let data = {}; stream.readString(length, data); return data.value; } /** * Iterates over all of the sub-streams of the given (possibly multi-part) * input stream, and yields a ConverterInputStream for each * nsIStringInputStream among them. * * @param {nsIInputStream|nsIMultiplexInputStream} outerStream * The multi-part stream over which to iterate. */ function* getTextStreams(outerStream) { for (let stream of getStreams(outerStream)) { if (stream instanceof Ci.nsIStringInputStream) { touchedStreams.add(outerStream); yield createTextStream(stream); } } } /** * Iterates over all of the string streams of the given (possibly * multi-part) input stream, and yields all of the available data in each as * chunked strings, each no more than BUFFER_SIZE in length. * * @param {nsIInputStream|nsIMultiplexInputStream} outerStream * The multi-part stream over which to iterate. */ function* readAllStrings(outerStream) { for (let textStream of getTextStreams(outerStream)) { let str; while ((str = readString(textStream))) { yield str; } } } /** * Iterates over the text contents of all of the string streams in the given * (possibly multi-part) input stream, splits them at occurrences of the * given boundary string, and yields each part. * * @param {nsIInputStream|nsIMultiplexInputStream} stream * The multi-part stream over which to iterate. * @param {string} boundary * The boundary at which to split the parts. * @param {string} [tail = ""] * Any initial data to prepend to the start of the stream data. */ function* getParts(stream, boundary, tail = "") { for (let chunk of readAllStrings(stream)) { chunk = tail + chunk; let parts = chunk.split(boundary); tail = parts.pop(); yield* parts; } if (tail) { yield tail; } } /** * Parses the given stream as multipart/form-data and returns a map of its fields. * * @param {nsIMultiplexInputStream|nsIInputStream} stream * The (possibly multi-part) stream to parse. * @param {string} boundary * The boundary at which to split the parts. * @returns {Map>} */ function parseMultiPart(stream, boundary) { let formData = new DefaultMap(() => []); for (let part of getParts(stream, boundary, "\r\n")) { if (part === "") { // The first part will always be empty. continue; } if (part === "--\r\n") { // This indicates the end of the stream. break; } let end = part.indexOf("\r\n\r\n"); // All valid parts must begin with \r\n, and we can't process form // fields without any header block. if (!part.startsWith("\r\n") || end <= 0) { throw new Error("Invalid MIME stream"); } let content = part.slice(end + 4); let headerText = part.slice(2, end); let headers = new Headers(headerText); let name = headers.getParam("content-disposition", "name"); if ( !name || headers.getParam("content-disposition", "") !== "form-data" ) { throw new Error( "Invalid MIME stream: No valid Content-Disposition header" ); } // Decode the percent-escapes in the name. Unlike with decodeURIComponent, // partial percent-escapes are passed through as is rather than throwing // exceptions. name = name.replace(/(%[0-9A-Fa-f]{2})+/g, match => { const bytes = new Uint8Array(match.length / 3); for (let i = 0; i < match.length / 3; i++) { bytes[i] = parseInt(match.substring(i * 3 + 1, (i + 1) * 3), 16); } return new TextDecoder("utf-8").decode(bytes); }); if (headers.has("content-type")) { // For file upload fields, we return the filename, rather than the // file data. We're following Chrome in not percent-decoding the // filename. let filename = headers.getParam("content-disposition", "filename"); content = filename || ""; } formData.get(name).push(content); } return formData; } /** * Parses the given stream as x-www-form-urlencoded, and returns a map of its fields. * * @param {nsIInputStream} stream * The stream to parse. * @returns {Map>} */ function parseUrlEncoded(stream) { let formData = new DefaultMap(() => []); for (let part of getParts(stream, "&")) { let [name, value] = part .replace(/\+/g, " ") .split("=") .map(decodeURIComponent); formData.get(name).push(value); } return formData; } try { if (stream instanceof Ci.nsIMIMEInputStream && stream.data) { stream = stream.data; } channel.QueryInterface(Ci.nsIHttpChannel); let contentType = channel.getRequestHeader("Content-Type"); switch (Headers.getParam(contentType, "")) { case "multipart/form-data": let boundary = Headers.getParam(contentType, "boundary"); return parseMultiPart(stream, `\r\n--${boundary}`); case "application/x-www-form-urlencoded": return parseUrlEncoded(stream); } } finally { for (let stream of touchedStreams) { rewind(stream); } for (let converterStream of converterStreams) { // Release the reference to the underlying input stream, to prevent the // destructor of nsConverterInputStream from closing the stream, which // would cause uploads to break. converterStream.init(null, null, 0, 0); } } return null; } /** * Parses the form data of the given stream as either multipart/form-data or * x-www-form-urlencoded, and returns a map of its fields. * * Returns null if the stream is not seekable. * * @param {nsIMultiplexInputStream|nsIInputStream} stream * The (possibly multi-part) stream from which to create the form data. * @param {nsIChannel} channel * The channel to which the stream belongs. * @param {boolean} [lenient = false] * If true, the operation will succeed even if there are UTF-8 * decoding errors. * @returns {Map> | null} */ function createFormData(stream, channel, lenient) { if (!(stream instanceof Ci.nsISeekableStream)) { return null; } try { let formData = parseFormData(stream, channel, lenient); if (formData) { return mapToObject(formData); } } catch (e) { Cu.reportError(e); } finally { rewind(stream); } return null; } /** * Iterates over all of the sub-streams of the given (possibly multi-part) * input stream, and yields an object containing the data for each chunk, up * to a total of `maxRead` bytes. * * @param {nsIMultiplexInputStream|nsIInputStream} outerStream * The stream for which to return data. * @param {integer} [maxRead = WebRequestUpload.MAX_RAW_BYTES] * The maximum total bytes to read. */ function* getRawDataChunked( outerStream, maxRead = WebRequestUpload.MAX_RAW_BYTES ) { for (let stream of getStreams(outerStream)) { // We need to inspect the stream to make sure it's not a file input // stream. If it's wrapped in a buffered input stream, unwrap it first, // so we can inspect the inner stream directly. let unbuffered = stream; if (stream instanceof Ci.nsIStreamBufferAccess) { unbuffered = stream.unbufferedStream; } // For file fields, we return an object containing the full path of // the file, rather than its data. if ( unbuffered instanceof Ci.nsIFileInputStream || unbuffered instanceof Ci.mozIRemoteLazyInputStream ) { // But this is not actually supported yet. yield { file: "" }; continue; } try { let binaryStream = BinaryInputStream(stream); let available; while ((available = binaryStream.available())) { let buffer = new ArrayBuffer(Math.min(maxRead, available)); binaryStream.readArrayBuffer(buffer.byteLength, buffer); maxRead -= buffer.byteLength; let chunk = { bytes: buffer }; if (buffer.byteLength < available) { chunk.truncated = true; chunk.originalSize = available; } yield chunk; if (maxRead <= 0) { return; } } } finally { rewind(stream); } } } WebRequestUpload = { createRequestBody(channel) { if (!(channel instanceof Ci.nsIUploadChannel) || !channel.uploadStream) { return null; } if ( channel instanceof Ci.nsIUploadChannel2 && channel.uploadStreamHasHeaders ) { return { error: "Upload streams with headers are unsupported" }; } try { let stream = channel.uploadStream; let formData = createFormData(stream, channel); if (formData) { return { formData }; } // If we failed to parse the stream as form data, return it as a // sequence of raw data chunks, along with a leniently-parsed form // data object, which ignores encoding errors. return { raw: Array.from(getRawDataChunked(stream)), lenientFormData: createFormData(stream, channel, true), }; } catch (e) { Cu.reportError(e); return { error: e.message || String(e) }; } }, }; XPCOMUtils.defineLazyPreferenceGetter( WebRequestUpload, "MAX_RAW_BYTES", "webextensions.webRequest.requestBodyMaxRawBytes" );