Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [master]
branches: [main]
pull_request:
branches: [master]
branches: [main]

jobs:
test:
Expand All @@ -23,6 +23,18 @@ jobs:
- run: node ./node_modules/mocha/bin/_mocha test
- run: node test/smoke-floor.js

test-qs-latest:
name: Conformance against qs@latest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm install --legacy-peer-deps --no-audit --no-fund
- run: npm install --no-save qs@latest
- run: node ./node_modules/mocha/bin/_mocha test/conformance.spec.js

floor:
name: Engines floor (Node ${{ matrix.node }} via Docker)
runs-on: ubuntu-latest
Expand All @@ -33,7 +45,12 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Smoke test on Node ${{ matrix.node }}
run: docker run --rm -v "$PWD":/app -w /app node:${{ matrix.node }} sh -c 'node test/smoke-floor.js'
run: |
docker run --rm -v "$PWD":/work -w /tmp/run node:${{ matrix.node }} sh -c '
cp -r /work/lib /work/index.js /work/package.json /work/test /tmp/run/ &&
npm install --production --no-audit --no-fund qs &&
node test/smoke-floor.js
'

audit:
name: npm audit (runtime only)
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
node-qs-serialization
===================
Serialization and deserialization of Javascript objects for use in the querystring part of an url without any external dependency.
Serialization and deserialization of Javascript objects for use in the querystring part of an url.
Slightly modified from jQuery's $.param function [$.param method](http://api.jquery.com/jQuery.param/) and Ben Alman's [jquery-bbq](https://github.com/cowboy/jquery-bbq/) with license info for both included.

`deparam` delegates parsing to [`qs`](https://github.com/ljharb/qs) and adds an ISO-8859 percent-encoding fallback, type coercion, and per-parameter depth and prototype-key rejection on top. `param` is the original pure-JS jQuery-traditional serializer.

param serializes any Javascript object to a valid querystring.
deparam deserializes a provided querystring.

Expand Down
185 changes: 77 additions & 108 deletions lib/deparam.js
Original file line number Diff line number Diff line change
@@ -1,124 +1,93 @@
/* global unescape */
'use strict';

var qs = require('qs');

var DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
var DEFAULT_MAX_DEPTH = 5;

exports.deparam = function(params, coerce, maxDepth) {
var obj = {};
var coerceTypes = {
true: !0,
false: !1,
null: null,
};
function safeDecodeURIComponent(str) {
var withSpaces = str.replace(/\+/g, ' ');
try { return decodeURIComponent(withSpaces); }
catch (e) { return unescape(withSpaces); }
}

if (typeof params !== 'string') {
return obj;
}
if (typeof coerce === 'undefined') {
coerce = true;
}
if (typeof maxDepth !== 'number' || maxDepth < 1) {
maxDepth = DEFAULT_MAX_DEPTH;
function safeDecoder(str, defaultDecoder, charset, type) {
if (type === 'key') return defaultDecoder(str, defaultDecoder, charset);
return safeDecodeURIComponent(str);
}

function keySegments(rawKey) {
var decoded = safeDecodeURIComponent(rawKey);
var match = /^([^[]*)((?:\[[^\]]*\])*)$/.exec(decoded);
if (!match) return [decoded];
var segments = [match[1]];
var bracketed = match[2];
if (bracketed) {
var bracketRe = /\[([^\]]*)\]/g;
var m;
while ((m = bracketRe.exec(bracketed))) segments.push(m[1]);
}
return segments;
}

function preFilter(qsString, maxDepth) {
if (!qsString) return qsString;
return qsString.split('&').filter(function(pair) {
var rawKey = pair.split('=')[0];
if (!rawKey) return false;
var segments = keySegments(rawKey);
var nonEmpty = segments.filter(function(s) { return s !== ''; });
if (nonEmpty.length > maxDepth) return false;
for (var i = 0; i < segments.length; i++) {
if (DANGEROUS_KEYS.indexOf(segments[i]) !== -1) return false;
}
return true;
}).join('&');
}

function safeDecodeURIComponent(component) {
var returnvalue = '';
try {
returnvalue = decodeURIComponent(component);
} catch (e) {
returnvalue = unescape(component);
function coerceScalar(v) {
if (v === null) return undefined;
if (v === undefined) return undefined;
if (typeof v !== 'string') return v;
if (v === '') return '';
if (v === 'true') return true;
if (v === 'false') return false;
if (v === 'null') return null;
if (v === 'undefined') return undefined;
if (!isNaN(+v)) return +v;
return v;
}

function coerceWalk(o) {
if (Array.isArray(o)) return o.map(coerceWalk);
if (o && typeof o === 'object') {
var out = {};
for (var k in o) {
if (Object.prototype.hasOwnProperty.call(o, k)) out[k] = coerceWalk(o[k]);
}
return returnvalue;
return out;
}
return coerceScalar(o);
}

// Iterate over all name=value pairs.
params.replace(/\+/g, ' ').split('&').forEach(function(element) {
var param = element.split('=');
var key = safeDecodeURIComponent(param[0]);
var val;
var cur = obj;
var i = 0;
exports.deparam = function(params, coerce, maxDepth) {
if (typeof params !== 'string') return {};
if (typeof coerce === 'undefined') coerce = true;
if (typeof maxDepth !== 'number' || maxDepth < 1) maxDepth = DEFAULT_MAX_DEPTH;

// If key is more complex than 'foo', like 'a[]' or 'a[b][c]', split it
// into its component parts.
var keys = key.split('][');
var keysLast = keys.length - 1;
var filtered = preFilter(params, maxDepth);
if (!filtered) return {};

// If the first keys part contains [ and the last ends with ], then []
// are correctly balanced.
if (/\[/.test(keys[0]) && /\]$/.test(keys[keysLast])) {
// Remove the trailing ] from the last keys part.
keys[keysLast] = keys[keysLast].replace(/\]$/, '');
// Split first keys part into two parts on the [ and add them back onto
// the beginning of the keys array.
keys = keys.shift().split('[').concat(keys);
keysLast = keys.length - 1;
} else {
// Basic 'foo' style key.
keysLast = 0;
}
if (keys.length > maxDepth) {
return;
}
for (var dk = 0; dk <= keysLast; dk++) {
if (DANGEROUS_KEYS.indexOf(keys[dk]) !== -1) {
return;
}
}
// Are we dealing with a name=value pair, or just a name?
if (param.length === 2) {
val = safeDecodeURIComponent(param[1]);
// Coerce values.
if (coerce) {
val = val && !isNaN(val) ? +val // number
: val === 'undefined' ? undefined // undefined
: coerceTypes[val] !== undefined ? coerceTypes[val] // true, false, null
: val; // string
}
if (keysLast) {
// Complex key, build deep object structure based on a few rules:
// * The 'cur' pointer starts at the object top-level
// * [] = array push (n is set to array length), [n] = array if n is
// numeric, otherwise object.
// * If at the last keys part, set the value.
// * For each keys part, if the current level is undefined create an
// object or array based on the type of the next keys part.
// * Move the 'cur' pointer to the next level.
// * Rinse & repeat.
for (; i <= keysLast; i++) {
key = keys[i] === '' ?
cur.length :
keys[i];
cur = cur[key] = i < keysLast ?
cur[key] || (keys[i + 1] && isNaN(keys[i + 1]) ?
{} :
[]
) :
val;
}
} else {
// Simple key, even simpler rules, since only scalars and shallow
// arrays are allowed.
if (Array.isArray(obj[key])) {
// val is already an array, so push on the next value.
obj[key].push(val);
} else if (obj[key] !== undefined) {
// val isn't an array, but since a second value has been specified,
// convert val into an array.
obj[key] = [
obj[key],
val
];
} else {
// val is a scalar.
obj[key] = val;
}
}
} else if (key) {
// No value was defined, so set something meaningful.
obj[key] = coerce ? undefined : '';
}
var parsed = qs.parse(filtered, {
decoder: safeDecoder,
depth: maxDepth,
strictDepth: false,
arrayLimit: 1000,
parameterLimit: 10000,
allowPrototypes: false,
strictNullHandling: !!coerce
});
return obj;

return coerce ? coerceWalk(parsed) : parsed;
};
8 changes: 0 additions & 8 deletions lib/param.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ module.exports.param = function(sourceObject) {
var rbracket = /\[\]$/;

function add(key, value) {
// If value is a function, invoke it and return its value
value = (typeof value === 'function') ?
value() :
value === null ?
Expand All @@ -20,35 +19,28 @@ module.exports.param = function(sourceObject) {
function buildParams(prefix, obj, add) {
var name;
if (Array.isArray(obj)) {
// Serialize array item.
for (var index = 0; index < obj.length; index++)
{
if (rbracket.test(prefix)) {
// Treat each array item as a scalar.
add(prefix, obj[index]);
} else {
// Item is non-scalar (array or object), encode its numeric index.
buildParams(prefix + '[' + (typeof (obj[index]) === 'object' ?
index :
''
) + ']', obj[index], add);
}
}
} else if (typeof obj === 'object') {
// Serialize object item.
for (name in obj) {
buildParams(prefix + '[' + name + ']', obj[name], add);
}
} else {
// Serialize scalar item.
add(prefix, obj);
}
}

// encode params recursively.
for (prefix in sourceObject) {
buildParams(prefix, sourceObject[prefix], add);
}
// Return the resulting serialization
return querystring.join('&').replace(r20, '+');
};
Loading
Loading