Skip to content

Commit

Permalink
feat(body-parser): add application/x-www-form-urlencoded
Browse files Browse the repository at this point in the history
add maxPayloadSize for body parsers (with 2MB default)
add .send() method
update vscode settings.json
  • Loading branch information
joaoneto committed Dec 28, 2023
1 parent 753a4d3 commit dc06d90
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/smart-ties-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'guarapi': minor
---

add application/x-www-form-urlencoded
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"extensions": [".js", ".jsx", ".ts", ".tsx"]
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"eslint.format.enable": true
}
7 changes: 7 additions & 0 deletions packages/guarapi/src/guarapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ function Guarapi(config?: GuarapiConfig): Guarapi {
return res;
};

res.send = (str) => {
res.setHeader('content-type', 'text/plain; charset=utf-8');
res.end(str);

return res;
};

return res;
};

Expand Down
56 changes: 52 additions & 4 deletions packages/guarapi/src/plugins/body-parser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import querystring from 'querystring';
import { Plugin } from '../types';

declare module '../types' {
Expand All @@ -6,13 +7,55 @@ declare module '../types' {
}
}

const bodyParserPlugin: Plugin = () => {
function convertFlatKeyToNestedKeys(
flatKey: string,
nestedObj: Record<string, unknown>,
value: unknown,
) {
const keys = flatKey
.replace(/\[(.*?)\]/g, '.$1')
.replace(/\.+/g, '.')
.split('.');
const lastKeyIndex = keys.length - 1;

keys.forEach((key, i) => {
const nestedKey = key || Object.keys(nestedObj).length.toString();
nestedObj[nestedKey] = i === lastKeyIndex ? value : nestedObj[nestedKey] || {};
nestedObj = nestedObj[nestedKey] as Record<string, unknown>;
});
}

function parseFormEncodedData(formEncodedData: string): Record<string, unknown> {
const parsedData = querystring.parse(formEncodedData);
const obj: Record<string, unknown> = {};

for (const key in parsedData) {
if (Object.prototype.hasOwnProperty.call(parsedData, key)) {
convertFlatKeyToNestedKeys(key, obj, parsedData[key]);
}
}

return obj;
}

const MAX_PAYLOAD_SIZE_BYTES = 2097152;

const bodyParserPlugin: Plugin = (_app, config) => {
const { maxPayloadSize = MAX_PAYLOAD_SIZE_BYTES } = config || {};
return {
name: 'bodyParser',
name: 'formUrlencodedParser',
pre: async (req, res, next) => {
const contentType = req.headers['content-type'];
const contentLength = req.headers['content-length']
? parseInt(req.headers['content-length'], 10)
: 0;

if (contentLength > maxPayloadSize) {
next(new Error(`Max payload ${maxPayloadSize}`));
return;
}

if (!contentType || !/application\/(.*\+)?json/.test(contentType)) {
if (!contentType || !/application\/((.*\+)?json|x-www-form-urlencoded)/.test(contentType)) {
next();
return;
}
Expand All @@ -25,7 +68,12 @@ const bodyParserPlugin: Plugin = () => {

req.on('end', () => {
try {
req.body = JSON.parse(Buffer.concat(buffers).toString());
const bodyData = Buffer.concat(buffers).toString();
if (contentType.includes('json')) {
req.body = JSON.parse(bodyData);
} else if (contentType.includes('x-www-form-urlencoded')) {
req.body = parseFormEncodedData(bodyData);
}
next();
} catch (error) {
next(error);
Expand Down
2 changes: 2 additions & 0 deletions packages/guarapi/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface Request extends IncomingMessage {
export interface Response extends ServerResponse {
status: (statusCode: number) => Response;
json: (obj: unknown) => Response;
send: (str: string) => Response;
}

export interface Middleware {
Expand All @@ -41,6 +42,7 @@ export interface ServerOptions extends HTTPServerOptions, HTTP2SecureServerOptio
export interface GuarapiConfig {
logger?: GuarapiLogger;
serverOptions?: ServerOptions;
maxPayloadSize?: number;
}

export interface PluginConfig {
Expand Down
99 changes: 98 additions & 1 deletion packages/guarapi/test/plugins/body-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import guarapi, {

describe('Guarapi - plugins/body-parser', () => {
const buildApp = () => {
const app = guarapi();
const app = guarapi({ maxPayloadSize: 1000 });
const server = createServer({}, app);

app.plugin(bodyParserPlugin);
Expand Down Expand Up @@ -87,4 +87,101 @@ describe('Guarapi - plugins/body-parser', () => {
expect(bodyHandler).not.toBeCalled();
expect(errorHandler).toBeCalled();
});

it('should parse x-www-form-urlencoded with malicious data', async () => {
const { app, server } = buildApp();
const body = jest.fn();
const maliciousPayload = "name=John&age=30&comment=<script>alert('XSS!')</script>";

app.use((req, res) => {
body(req.body);
res.end();
});

await request(server)
.post('/')
.set('Content-type', 'application/x-www-form-urlencoded')
.send(maliciousPayload)
.expect(200);

expect(body).toBeCalledWith({
age: '30',
comment: "<script>alert('XSS!')</script>",
name: 'John',
});
});

it('should handle x-www-form-urlencoded with invalid UTF-8 characters', async () => {
const { app, server } = buildApp();
const body = jest.fn();
const invalidPayload = 'name=John&age=30&comment=\uD800\uDC00';

app.use((req, res) => {
body(req.body);
res.end();
});

await request(server)
.post('/')
.set('Content-type', 'application/x-www-form-urlencoded')
.send(invalidPayload)
.expect(200);

expect(body).toBeCalledWith({ age: '30', comment: '𐀀', name: 'John' });
});

it('should handle malformed x-www-form-urlencoded data', async () => {
const { app, server } = buildApp();
const bodyHandler = jest.fn();
const errorHandler = jest.fn();
const malformedPayload = 'name=Guarapi%7D%20%7Bmalformed=true';

app.use((req, res) => {
bodyHandler(req.body);
res.end();
});

app.use<MiddlewareError>((error, req, res, _next) => {
errorHandler();
res.end();
});

await request(server)
.post('/')
.set('Content-type', 'application/x-www-form-urlencoded')
.send(malformedPayload)
.expect(200);

expect(bodyHandler).toBeCalledWith({ name: 'Guarapi} {malformed=true' });
expect(errorHandler).not.toBeCalled();
});

it('should handle x-www-form-urlencoded with payload overflow', async () => {
const { app, server } = buildApp();
const bodyHandler = jest.fn();
const errorHandler = jest.fn();

const largePayload = 'name=' + 'A'.repeat(1000000);

app.use((req, res) => {
bodyHandler(req.body);
res.end();
});

app.use<MiddlewareError>((error, req, res, _next) => {
errorHandler();
res.status(400).send('Payload too large');
res.end();
});

const response = await request(server)
.post('/')
.set('Content-type', 'application/x-www-form-urlencoded')
.send(largePayload)
.expect(400);

expect(bodyHandler).not.toBeCalled();
expect(errorHandler).toBeCalled();
expect(response.text).toBe('Payload too large');
});
});

0 comments on commit dc06d90

Please sign in to comment.