@auth70/bodyguard
Version:
Fetch API compatible streaming JSON and form data body parser and guard
229 lines (228 loc) • 9.62 kB
JavaScript
import { JSONParser as JSONStreamingParser, TokenType } from '@streamparser/json';
import { ERRORS, extractNestedKey, createByteStreamCounter, assignNestedValue, possibleCast } from './lib.js';
import parseMultipartMessage from '@apeleghq/multipart-parser';
export class TextParser {
config;
depth = 0;
keyCount = 0;
constructor(config) {
this.config = config;
}
async parse(stream) {
return new Promise(async (resolve, reject) => {
const decoder = new TextDecoder();
let result = '';
const byteStreamCounter = createByteStreamCounter(stream, this.config.maxSize, reject);
const reader = stream.pipeThrough(byteStreamCounter).getReader();
while (true) {
const { done, value } = await reader.read();
if (done)
break;
result += decoder.decode(value);
}
resolve(result);
});
}
}
export class JSONParser {
config;
depth = 0;
keyCount = 0;
constructor(config) {
this.config = config;
}
async parse(stream) {
return new Promise(async (resolve, reject) => {
const jsonparser = new JSONStreamingParser();
jsonparser.onToken = ({ token, value, offset }) => {
if (token === TokenType.COLON) {
this.keyCount++;
if (this.keyCount > this.config.maxKeys) {
reject(new Error(ERRORS.TOO_MANY_KEYS));
}
}
else if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) {
this.depth++;
if (this.depth > this.config.maxDepth) {
reject(new Error(ERRORS.TOO_DEEP));
}
}
else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) {
this.depth--;
}
};
jsonparser.onValue = ({ value, key, parent, stack }) => {
if (key === '__proto__')
reject(new Error(ERRORS.INVALID_INPUT));
if (key && typeof key === "string" && key.length > this.config.maxKeyLength)
reject(new Error(ERRORS.KEY_TOO_LONG));
if (stack.length > 0)
return;
resolve(value);
};
jsonparser.onError = (error) => {
reject(new Error(ERRORS.INVALID_INPUT));
};
const byteStreamCounter = createByteStreamCounter(stream, this.config.maxSize, reject);
const reader = stream.pipeThrough(byteStreamCounter).getReader();
while (true) {
const { done, value } = await reader.read();
if (done)
break;
jsonparser.write(value);
}
});
}
}
export class URLParamsParser {
config;
depth = 0;
keyCount = 0;
state = 'KEY';
currentKey = "";
currentValue = "";
EQUALS = '='.charCodeAt(0);
AMPERSAND = '&'.charCodeAt(0);
constructor(config) {
this.config = config;
}
async parse(stream) {
const obj = {};
const byteStreamCounter = createByteStreamCounter(stream, this.config.maxSize);
for await (const part of this.parseStream(stream.pipeThrough(byteStreamCounter))) {
if (!part.key || part.key === '')
continue;
if (part.keyCount > this.config.maxKeys)
throw new Error(ERRORS.TOO_MANY_KEYS);
if (part.key.length > this.config.maxKeyLength)
throw new Error(ERRORS.KEY_TOO_LONG);
const path = extractNestedKey(part.key);
if (path.find(s => s === "__proto__"))
throw new Error(ERRORS.INVALID_INPUT);
if (path.length > this.config.maxDepth)
throw new Error(ERRORS.TOO_DEEP);
assignNestedValue(obj, path, possibleCast(decodeURIComponent(part.value), this.config));
}
return obj;
}
async *parseStream(stream) {
const reader = stream.getReader();
let done, value;
while ({ done, value } = await reader.read(), !done) {
if (!value)
continue;
for (const byte of value) {
switch (this.state) {
case 'KEY':
if (byte === this.EQUALS) {
this.state = 'VALUE';
}
else if (byte === this.AMPERSAND) {
this.keyCount++;
yield { key: this.currentKey, value: this.currentValue, keyCount: this.keyCount };
this.currentKey = "";
this.currentValue = "";
}
else {
this.currentKey += String.fromCharCode(byte);
}
break;
case 'VALUE':
if (byte === this.AMPERSAND) {
this.state = 'KEY';
this.keyCount++;
yield { key: this.currentKey, value: this.currentValue, keyCount: this.keyCount };
this.currentKey = "";
this.currentValue = "";
}
else {
this.currentValue += String.fromCharCode(byte);
}
break;
}
}
}
// Handle the last parameter, if there's any left
if (this.currentKey || this.currentValue) {
yield { key: this.currentKey, value: this.currentValue, keyCount: this.keyCount };
}
}
}
export class FormDataParser {
config;
depth = 0;
keyCount = 0;
fileCount = 0;
boundary = '';
constructor(config, boundary) {
this.config = config;
this.boundary = boundary;
}
async parse(stream) {
const decoder = new TextDecoder();
const byteStreamCounter = createByteStreamCounter(stream, this.config.maxSize);
const result = parseMultipartMessage(stream.pipeThrough(byteStreamCounter), this.boundary);
/**
* Parse an incoming stream of multipart/form-data
* @param {TMultipartMessageGenerator} result A generator that yields parts of the multipart/form-data
* @returns {Promise<Record<string, any>>} A promise that resolves to a Map of the form-data
*/
const inner = async (result) => {
let ret = {};
this.depth++;
if (this.depth >= this.config.maxDepth)
throw new Error(ERRORS.TOO_DEEP);
for await (const part of result) {
const key = part.headers.get('content-disposition');
if (!key || !key.startsWith('form-data'))
continue;
const contentType = part.headers.get('content-type') || 'text/plain';
// Check if content type is allowed
if (this.config.allowedContentTypes && !this.config.allowedContentTypes.includes(contentType))
throw new Error(ERRORS.INVALID_CONTENT_TYPE);
this.keyCount++;
if (this.keyCount >= this.config.maxKeys)
throw new Error(ERRORS.TOO_MANY_KEYS);
// Check if the part is a file
const isFile = key.indexOf('; filename=') !== -1;
// Check if the file count is exceeded
if (isFile)
this.fileCount++;
if (this.fileCount >= this.config.maxFiles)
throw new Error(ERRORS.TOO_MANY_FILES);
// Extract the key name and the filename, if it's a file
const match = !isFile ? key.match(/name="(.*)"/) : key.match(/name="(.*)"; filename="(.*)"/);
// If the key name is not available, skip the part
if (!match || !match[1] || match[1] === '')
continue;
const filename = match && match[2] ? match[2] : '';
if (filename.length > this.config.maxFilenameLength)
throw new Error(ERRORS.FILENAME_TOO_LONG);
const keyName = match[1];
if (keyName === '__proto__')
throw new Error(ERRORS.INVALID_INPUT);
if (keyName.length > this.config.maxKeyLength)
throw new Error(ERRORS.KEY_TOO_LONG);
let body = '';
if (part.parts) {
body = await inner(part.parts);
}
else {
if (isFile) {
// If the part is a file, return an object with the filename and the content type
body = part.body ? new File([part.body], filename, { type: contentType }) : '';
}
else {
body = part.body ? possibleCast(decoder.decode(part.body), this.config) : '';
}
}
const path = extractNestedKey(keyName);
assignNestedValue(ret, path, body);
}
this.depth--;
return ret;
};
const res = await inner(result);
return res;
}
}