mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 17:18:32 +02:00
Fix multipart form data parsing from cURL --data-raw (#331)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -194,11 +194,17 @@ function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
|
|||||||
let value: string | boolean;
|
let value: string | boolean;
|
||||||
const nextEntry = parseEntries[i + 1];
|
const nextEntry = parseEntries[i + 1];
|
||||||
const hasValue = !BOOLEAN_FLAGS.includes(name);
|
const hasValue = !BOOLEAN_FLAGS.includes(name);
|
||||||
|
// Check if nextEntry looks like a flag:
|
||||||
|
// - Single dash followed by a letter: -X, -H, -d
|
||||||
|
// - Double dash followed by a letter: --data-raw, --header
|
||||||
|
// This prevents mistaking data that starts with dashes (like multipart boundaries ------) as flags
|
||||||
|
const nextEntryIsFlag = typeof nextEntry === 'string' &&
|
||||||
|
(nextEntry.match(/^-[a-zA-Z]/) || nextEntry.match(/^--[a-zA-Z]/));
|
||||||
if (isSingleDash && name.length > 1) {
|
if (isSingleDash && name.length > 1) {
|
||||||
// Handle squished arguments like -XPOST
|
// Handle squished arguments like -XPOST
|
||||||
value = name.slice(1);
|
value = name.slice(1);
|
||||||
name = name.slice(0, 1);
|
name = name.slice(0, 1);
|
||||||
} else if (typeof nextEntry === 'string' && hasValue && !nextEntry.startsWith('-')) {
|
} else if (typeof nextEntry === 'string' && hasValue && !nextEntryIsFlag) {
|
||||||
// Next arg is not a flag, so assign it as the value
|
// Next arg is not a flag, so assign it as the value
|
||||||
value = nextEntry;
|
value = nextEntry;
|
||||||
i++; // Skip next one
|
i++; // Skip next one
|
||||||
@@ -305,11 +311,32 @@ function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Body (Text or Blob)
|
// Body (Text or Blob)
|
||||||
const dataParameters = pairsToDataParameters(flagsByName);
|
|
||||||
const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === 'content-type');
|
const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === 'content-type');
|
||||||
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(';')[0] : null;
|
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(';')[0]?.trim() : null;
|
||||||
|
|
||||||
// Body (Multipart Form Data)
|
// Extract boundary from Content-Type header for multipart parsing
|
||||||
|
const boundaryMatch = contentTypeHeader?.value.match(/boundary=([^\s;]+)/i);
|
||||||
|
const boundary = boundaryMatch?.[1];
|
||||||
|
|
||||||
|
// Get raw data from --data-raw flags (before splitting by &)
|
||||||
|
const rawDataValues = [
|
||||||
|
...((flagsByName['data-raw'] as string[] | undefined) || []),
|
||||||
|
...((flagsByName.d as string[] | undefined) || []),
|
||||||
|
...((flagsByName.data as string[] | undefined) || []),
|
||||||
|
...((flagsByName['data-binary'] as string[] | undefined) || []),
|
||||||
|
...((flagsByName['data-ascii'] as string[] | undefined) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if this is multipart form data in --data-raw (Chrome DevTools format)
|
||||||
|
let multipartFormDataFromRaw: { name: string; value?: string; file?: string; enabled: boolean }[] | null = null;
|
||||||
|
if (mimeType === 'multipart/form-data' && boundary && rawDataValues.length > 0) {
|
||||||
|
const rawBody = rawDataValues.join('');
|
||||||
|
multipartFormDataFromRaw = parseMultipartFormData(rawBody, boundary);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataParameters = pairsToDataParameters(flagsByName);
|
||||||
|
|
||||||
|
// Body (Multipart Form Data from -F flags)
|
||||||
const formDataParams = [
|
const formDataParams = [
|
||||||
...((flagsByName.form as string[] | undefined) || []),
|
...((flagsByName.form as string[] | undefined) || []),
|
||||||
...((flagsByName.F as string[] | undefined) || []),
|
...((flagsByName.F as string[] | undefined) || []),
|
||||||
@@ -336,7 +363,13 @@ function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
|
|||||||
let bodyType: string | null = null;
|
let bodyType: string | null = null;
|
||||||
const bodyAsGET = getPairValue(flagsByName, false, ['G', 'get']);
|
const bodyAsGET = getPairValue(flagsByName, false, ['G', 'get']);
|
||||||
|
|
||||||
if (dataParameters.length > 0 && bodyAsGET) {
|
if (multipartFormDataFromRaw) {
|
||||||
|
// Handle multipart form data parsed from --data-raw (Chrome DevTools format)
|
||||||
|
bodyType = 'multipart/form-data';
|
||||||
|
body = {
|
||||||
|
form: multipartFormDataFromRaw,
|
||||||
|
};
|
||||||
|
} else if (dataParameters.length > 0 && bodyAsGET) {
|
||||||
urlParameters.push(...dataParameters);
|
urlParameters.push(...dataParameters);
|
||||||
} else if (
|
} else if (
|
||||||
dataParameters.length > 0 &&
|
dataParameters.length > 0 &&
|
||||||
@@ -473,6 +506,71 @@ function splitOnce(str: string, sep: string): string[] {
|
|||||||
return [str];
|
return [str];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses multipart form data from a raw body string
|
||||||
|
* Used when Chrome DevTools exports a cURL with --data-raw containing multipart data
|
||||||
|
*/
|
||||||
|
function parseMultipartFormData(
|
||||||
|
rawBody: string,
|
||||||
|
boundary: string,
|
||||||
|
): { name: string; value?: string; file?: string; enabled: boolean }[] | null {
|
||||||
|
const results: { name: string; value?: string; file?: string; enabled: boolean }[] = [];
|
||||||
|
|
||||||
|
// The boundary in the body typically has -- prefix
|
||||||
|
const boundaryMarker = `--${boundary}`;
|
||||||
|
const parts = rawBody.split(boundaryMarker);
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
// Skip empty parts and the closing boundary marker
|
||||||
|
if (!part || part.trim() === '--' || part.trim() === '--\r\n') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each part has headers and content separated by \r\n\r\n
|
||||||
|
const headerContentSplit = part.indexOf('\r\n\r\n');
|
||||||
|
if (headerContentSplit === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerSection = part.slice(0, headerContentSplit);
|
||||||
|
let content = part.slice(headerContentSplit + 4); // Skip \r\n\r\n
|
||||||
|
|
||||||
|
// Remove trailing \r\n from content
|
||||||
|
if (content.endsWith('\r\n')) {
|
||||||
|
content = content.slice(0, -2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Content-Disposition header to get name and filename
|
||||||
|
const contentDispositionMatch = headerSection.match(
|
||||||
|
/Content-Disposition:\s*form-data;\s*name="([^"]+)"(?:;\s*filename="([^"]+)")?/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!contentDispositionMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = contentDispositionMatch[1] ?? '';
|
||||||
|
const filename = contentDispositionMatch[2];
|
||||||
|
|
||||||
|
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
|
||||||
|
name,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filename) {
|
||||||
|
// This is a file upload field
|
||||||
|
item.file = filename;
|
||||||
|
} else {
|
||||||
|
// This is a regular text field
|
||||||
|
item.value = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.length > 0 ? results : null;
|
||||||
|
}
|
||||||
|
|
||||||
const idCount: Partial<Record<string, number>> = {};
|
const idCount: Partial<Record<string, number>> = {};
|
||||||
|
|
||||||
function generateId(model: string): string {
|
function generateId(model: string): string {
|
||||||
|
|||||||
@@ -441,6 +441,72 @@ describe('importer-curl', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Imports multipart form data from --data-raw (Chrome DevTools format)', () => {
|
||||||
|
// This is the format Chrome DevTools uses when copying a multipart form submission as cURL
|
||||||
|
const curlCommand = `curl 'http://localhost:8080/system' \
|
||||||
|
-H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd' \
|
||||||
|
--data-raw $'------WebKitFormBoundaryHwsXKi4rKA6P5VBd\r\nContent-Disposition: form-data; name="username"\r\n\r\njsgj\r\n------WebKitFormBoundaryHwsXKi4rKA6P5VBd\r\nContent-Disposition: form-data; name="password"\r\n\r\n654321\r\n------WebKitFormBoundaryHwsXKi4rKA6P5VBd\r\nContent-Disposition: form-data; name="captcha"; filename="test.xlsx"\r\nContent-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n\r\n------WebKitFormBoundaryHwsXKi4rKA6P5VBd--\r\n'`;
|
||||||
|
|
||||||
|
expect(convertCurl(curlCommand)).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: 'http://localhost:8080/system',
|
||||||
|
method: 'POST',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
name: 'Content-Type',
|
||||||
|
value: 'multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bodyType: 'multipart/form-data',
|
||||||
|
body: {
|
||||||
|
form: [
|
||||||
|
{ name: 'username', value: 'jsgj', enabled: true },
|
||||||
|
{ name: 'password', value: '654321', enabled: true },
|
||||||
|
{ name: 'captcha', file: 'test.xlsx', enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Imports multipart form data with text-only fields from --data-raw', () => {
|
||||||
|
const curlCommand = `curl 'http://example.com/api' \
|
||||||
|
-H 'Content-Type: multipart/form-data; boundary=----FormBoundary123' \
|
||||||
|
--data-raw $'------FormBoundary123\r\nContent-Disposition: form-data; name="field1"\r\n\r\nvalue1\r\n------FormBoundary123\r\nContent-Disposition: form-data; name="field2"\r\n\r\nvalue2\r\n------FormBoundary123--\r\n'`;
|
||||||
|
|
||||||
|
expect(convertCurl(curlCommand)).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: 'http://example.com/api',
|
||||||
|
method: 'POST',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
name: 'Content-Type',
|
||||||
|
value: 'multipart/form-data; boundary=----FormBoundary123',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bodyType: 'multipart/form-data',
|
||||||
|
body: {
|
||||||
|
form: [
|
||||||
|
{ name: 'field1', value: 'value1', enabled: true },
|
||||||
|
{ name: 'field2', value: 'value2', enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const idCount: Partial<Record<string, number>> = {};
|
const idCount: Partial<Record<string, number>> = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user