payload-guard-filter 1.0.1 ā 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +44 -0
- package/dist/core/compiler.d.ts +4 -0
- package/dist/core/compiler.js +12 -3
- package/dist/core/perf.d.ts +3 -0
- package/dist/core/perf.js +17 -1
- package/dist/core/types.d.ts +13 -1
- package/dist/middleware/express.js +47 -11
- package/package.json +8 -6
package/dist/cli.js
CHANGED
|
@@ -149,16 +149,57 @@ function init() {
|
|
|
149
149
|
console.log(' 2. Import shapes into your Express routes');
|
|
150
150
|
console.log(' 3. Use res.guardJson(shape, data) to filter responses\n');
|
|
151
151
|
}
|
|
152
|
+
function sync() {
|
|
153
|
+
const targetDir = process.cwd();
|
|
154
|
+
const shapesDir = path.join(targetDir, 'src', 'shapes');
|
|
155
|
+
const outputPath = path.join(targetDir, 'src', 'types', 'generated.ts');
|
|
156
|
+
console.log('\nš payload-guard sync\n');
|
|
157
|
+
if (!fs.existsSync(shapesDir)) {
|
|
158
|
+
console.log(`ā Shapes directory not found: ${shapesDir}`);
|
|
159
|
+
console.log('Use "npx payload-guard init" to create basic shapes first.\n');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const files = fs.readdirSync(shapesDir).filter(f => f.endsWith('.ts'));
|
|
163
|
+
if (files.length === 0) {
|
|
164
|
+
console.log('ā ļø No shape files found to sync.\n');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let content = `/**\n * Generated by payload-guard sync\n * DO NOT EDIT MANUALLY\n */\n\nimport { InferShape } from 'payload-guard';\n`;
|
|
168
|
+
files.forEach(file => {
|
|
169
|
+
const filePath = path.join(shapesDir, file);
|
|
170
|
+
const fileName = path.parse(file).name;
|
|
171
|
+
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
172
|
+
// Simple regex to find exported shapes
|
|
173
|
+
const exportRegex = /export const (\w+) = (guard\.shape|buildShape)\(/g;
|
|
174
|
+
let match;
|
|
175
|
+
content += `\n// From ${file}\nimport * as ${fileName}Shapes from '../shapes/${fileName}';\n`;
|
|
176
|
+
while ((match = exportRegex.exec(fileContent)) !== null) {
|
|
177
|
+
const shapeName = match[1];
|
|
178
|
+
const typeName = shapeName.endsWith('Shape')
|
|
179
|
+
? shapeName.replace('Shape', '')
|
|
180
|
+
: shapeName.charAt(0).toUpperCase() + shapeName.slice(1);
|
|
181
|
+
content += `export type ${typeName} = InferShape<typeof ${fileName}Shapes.${shapeName}>;\n`;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
const outputDir = path.dirname(outputPath);
|
|
185
|
+
if (!fs.existsSync(outputDir)) {
|
|
186
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
187
|
+
}
|
|
188
|
+
fs.writeFileSync(outputPath, content);
|
|
189
|
+
console.log(`ā
Generated types in: ${outputPath}\n`);
|
|
190
|
+
}
|
|
152
191
|
function showHelp() {
|
|
153
192
|
console.log(`
|
|
154
193
|
š”ļø payload-guard CLI
|
|
155
194
|
|
|
156
195
|
Usage:
|
|
157
196
|
npx payload-guard init Create example files in current directory
|
|
197
|
+
npx payload-guard sync Sync shapes to TypeScript types
|
|
158
198
|
npx payload-guard help Show this help message
|
|
159
199
|
|
|
160
200
|
Examples:
|
|
161
201
|
npx payload-guard init
|
|
202
|
+
npx payload-guard sync
|
|
162
203
|
`);
|
|
163
204
|
}
|
|
164
205
|
// Parse arguments
|
|
@@ -168,6 +209,9 @@ switch (command) {
|
|
|
168
209
|
case 'init':
|
|
169
210
|
init();
|
|
170
211
|
break;
|
|
212
|
+
case 'sync':
|
|
213
|
+
sync();
|
|
214
|
+
break;
|
|
171
215
|
case 'help':
|
|
172
216
|
case '--help':
|
|
173
217
|
case '-h':
|
package/dist/core/compiler.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ interface CompileOptions {
|
|
|
5
5
|
strict?: boolean;
|
|
6
6
|
redact?: (key: string, value: unknown) => unknown;
|
|
7
7
|
logger?: (msg: string, level?: 'warn' | 'error' | 'info') => void;
|
|
8
|
+
/** Maximum array length to process (memory safety) */
|
|
9
|
+
maxArrayLength?: number;
|
|
10
|
+
/** Parent path for nested field warnings (internal) */
|
|
11
|
+
_parentPath?: string;
|
|
8
12
|
}
|
|
9
13
|
/**
|
|
10
14
|
* Compile a shape descriptor into an optimized filter function
|
package/dist/core/compiler.js
CHANGED
|
@@ -57,11 +57,20 @@ function compile(shape, opts = {}) {
|
|
|
57
57
|
}
|
|
58
58
|
// Array shape
|
|
59
59
|
if (isArrayShape(shape)) {
|
|
60
|
-
const itemFn = compile(shape.item, opts);
|
|
60
|
+
const itemFn = compile(shape.item, { ...opts, _parentPath: (opts._parentPath || '') + '[]' });
|
|
61
61
|
return (value) => {
|
|
62
62
|
if (!Array.isArray(value))
|
|
63
63
|
return [];
|
|
64
|
-
|
|
64
|
+
// Memory safety: slice array if maxArrayLength is set
|
|
65
|
+
let arr = value;
|
|
66
|
+
if (opts.maxArrayLength && arr.length > opts.maxArrayLength) {
|
|
67
|
+
if (opts.dev) {
|
|
68
|
+
const path = opts._parentPath || 'array';
|
|
69
|
+
(opts.logger ?? console.warn)(`[payload-guard] ${path}: array truncated from ${arr.length} to ${opts.maxArrayLength} items (maxArrayLength)`, 'warn');
|
|
70
|
+
}
|
|
71
|
+
arr = arr.slice(0, opts.maxArrayLength);
|
|
72
|
+
}
|
|
73
|
+
return arr.map(item => {
|
|
65
74
|
try {
|
|
66
75
|
return itemFn(item);
|
|
67
76
|
}
|
|
@@ -92,7 +101,7 @@ function compile(shape, opts = {}) {
|
|
|
92
101
|
}
|
|
93
102
|
continue;
|
|
94
103
|
}
|
|
95
|
-
fieldFilters[key] = compile(shapeObj[key], opts);
|
|
104
|
+
fieldFilters[key] = compile(shapeObj[key], { ...opts, _parentPath: (opts._parentPath ? opts._parentPath + '.' : '') + key });
|
|
96
105
|
}
|
|
97
106
|
return (value) => {
|
|
98
107
|
if (value == null || typeof value !== 'object')
|
package/dist/core/perf.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export declare function byteSize(v: unknown): number;
|
|
2
|
+
export declare function formatBytes(bytes: number): string;
|
|
2
3
|
export declare function metric(before: unknown, after: unknown): {
|
|
3
4
|
before: number;
|
|
4
5
|
after: number;
|
|
5
6
|
saved: number;
|
|
6
7
|
percent: number;
|
|
8
|
+
formatBefore: string;
|
|
9
|
+
formatAfter: string;
|
|
7
10
|
};
|
package/dist/core/perf.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.byteSize = byteSize;
|
|
4
|
+
exports.formatBytes = formatBytes;
|
|
4
5
|
exports.metric = metric;
|
|
5
6
|
function byteSize(v) {
|
|
6
7
|
try {
|
|
@@ -10,10 +11,25 @@ function byteSize(v) {
|
|
|
10
11
|
return 0;
|
|
11
12
|
}
|
|
12
13
|
}
|
|
14
|
+
function formatBytes(bytes) {
|
|
15
|
+
if (bytes === 0)
|
|
16
|
+
return '0B';
|
|
17
|
+
const k = 1024;
|
|
18
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
19
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
20
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + sizes[i];
|
|
21
|
+
}
|
|
13
22
|
function metric(before, after) {
|
|
14
23
|
const b = byteSize(before);
|
|
15
24
|
const a = byteSize(after);
|
|
16
25
|
const saved = Math.max(0, b - a);
|
|
17
26
|
const pct = b === 0 ? 0 : Math.round((saved / b) * 10000) / 100;
|
|
18
|
-
return {
|
|
27
|
+
return {
|
|
28
|
+
before: b,
|
|
29
|
+
after: a,
|
|
30
|
+
saved,
|
|
31
|
+
percent: pct,
|
|
32
|
+
formatBefore: formatBytes(b),
|
|
33
|
+
formatAfter: formatBytes(a)
|
|
34
|
+
};
|
|
19
35
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -31,14 +31,26 @@ export interface GuardConfig {
|
|
|
31
31
|
strict?: boolean;
|
|
32
32
|
/** Redaction hook: return replacement value for a key */
|
|
33
33
|
redact?: (key: string, value: unknown) => unknown;
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Logger integration hook for custom logging (pino, winston, etc.)
|
|
36
|
+
* @example
|
|
37
|
+
* logger: (msg, level) => pino[level || 'info'](msg)
|
|
38
|
+
*/
|
|
35
39
|
logger?: (msg: string, level?: 'warn' | 'error' | 'info') => void;
|
|
36
40
|
/** Maximum payload size (bytes) for middleware processing; if exceeded, processing is skipped */
|
|
37
41
|
maxPayloadSize?: number;
|
|
42
|
+
/** Skip processing if payload exceeds maxPayloadSize (default: true) */
|
|
43
|
+
skipLargePayload?: boolean;
|
|
44
|
+
/** Maximum array length to process (memory safety) */
|
|
45
|
+
maxArrayLength?: number;
|
|
38
46
|
/** Routes to ignore (prefix match) */
|
|
39
47
|
ignoreRoutes?: string[];
|
|
40
48
|
/** Enable payload metrics (measure before/after sizes) */
|
|
41
49
|
payloadMetrics?: boolean;
|
|
50
|
+
/** Sanitize request headers (remove sensitive headers) */
|
|
51
|
+
sanitizeHeaders?: boolean;
|
|
52
|
+
/** Sensitive header names to filter (default: authorization, cookie, x-api-key) */
|
|
53
|
+
sensitiveHeaders?: string[];
|
|
42
54
|
}
|
|
43
55
|
export interface GuardMiddlewareOptions extends GuardConfig {
|
|
44
56
|
/** Sanitize request body */
|
|
@@ -19,10 +19,12 @@ const security_1 = require("../core/security");
|
|
|
19
19
|
* ```
|
|
20
20
|
*/
|
|
21
21
|
function guardMiddleware(opts = {}) {
|
|
22
|
-
const { sanitizeBody = false, requestShape, sensitiveFields = [], devMode = false, filterResponse = false, maxPayloadSize, ignoreRoutes = [], payloadMetrics = false, } = opts;
|
|
22
|
+
const { sanitizeBody = false, requestShape, sensitiveFields = [], devMode = false, filterResponse = false, maxPayloadSize, skipLargePayload = true, maxArrayLength, ignoreRoutes = [], payloadMetrics = false, sanitizeHeaders = false, sensitiveHeaders = ['authorization', 'cookie', 'x-api-key', 'x-auth-token'], } = opts;
|
|
23
|
+
// Default sensitive headers
|
|
24
|
+
const DEFAULT_SENSITIVE_HEADERS = ['authorization', 'cookie', 'x-api-key', 'x-auth-token', 'x-session-id'];
|
|
23
25
|
// Pre-compile request shape if provided
|
|
24
26
|
const compiledRequestShape = requestShape
|
|
25
|
-
? (0, compiler_1.compile)(requestShape, { sensitive: sensitiveFields, dev: devMode })
|
|
27
|
+
? (0, compiler_1.compile)(requestShape, { sensitive: sensitiveFields, dev: devMode, maxArrayLength, logger: opts.logger })
|
|
26
28
|
: null;
|
|
27
29
|
// Response filter cache for shapes to avoid recompiling per request
|
|
28
30
|
const responseFilterCache = new WeakMap();
|
|
@@ -32,6 +34,20 @@ function guardMiddleware(opts = {}) {
|
|
|
32
34
|
if (ignoreRoutes && ignoreRoutes.some(p => req.path.startsWith(p))) {
|
|
33
35
|
return next();
|
|
34
36
|
}
|
|
37
|
+
const routeName = `[${req.method}] ${req.path}`;
|
|
38
|
+
// Sanitize headers if enabled
|
|
39
|
+
if (sanitizeHeaders && req.headers) {
|
|
40
|
+
const allSensitiveHeaders = [...DEFAULT_SENSITIVE_HEADERS, ...(sensitiveHeaders || [])];
|
|
41
|
+
for (const header of allSensitiveHeaders) {
|
|
42
|
+
const headerLower = header.toLowerCase();
|
|
43
|
+
if (req.headers[headerLower]) {
|
|
44
|
+
if (devMode) {
|
|
45
|
+
(opts.logger ?? console.warn)(`[payload-guard] ${routeName}: Sensitive header "${header}" removed`, 'warn');
|
|
46
|
+
}
|
|
47
|
+
delete req.headers[headerLower];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
35
51
|
// Sanitize request body
|
|
36
52
|
if (sanitizeBody && req.body && typeof req.body === 'object') {
|
|
37
53
|
// max payload size check
|
|
@@ -40,9 +56,17 @@ function guardMiddleware(opts = {}) {
|
|
|
40
56
|
const size = Buffer.byteLength(JSON.stringify(req.body || ''), 'utf8');
|
|
41
57
|
if (size > maxPayloadSize) {
|
|
42
58
|
if (devMode) {
|
|
43
|
-
opts.logger
|
|
59
|
+
(opts.logger ?? console.warn)(`[payload-guard] ${routeName}: payload exceeds maxPayloadSize (${size} > ${maxPayloadSize} bytes)`, 'warn');
|
|
60
|
+
}
|
|
61
|
+
// Skip processing if skipLargePayload is true (default), else still process
|
|
62
|
+
if (!skipLargePayload) {
|
|
63
|
+
if (compiledRequestShape) {
|
|
64
|
+
req.body = compiledRequestShape(req.body);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
req.body = (0, security_1.removeSensitive)(req.body, sensitiveFields);
|
|
68
|
+
}
|
|
44
69
|
}
|
|
45
|
-
// do not block; skip processing
|
|
46
70
|
}
|
|
47
71
|
else {
|
|
48
72
|
if (compiledRequestShape) {
|
|
@@ -56,7 +80,7 @@ function guardMiddleware(opts = {}) {
|
|
|
56
80
|
catch (e) {
|
|
57
81
|
// if serialization fails, skip and continue
|
|
58
82
|
if (devMode)
|
|
59
|
-
opts.logger
|
|
83
|
+
(opts.logger ?? console.warn)('[payload-guard] error checking payload size: ' + String(e), 'warn');
|
|
60
84
|
}
|
|
61
85
|
}
|
|
62
86
|
else {
|
|
@@ -68,13 +92,20 @@ function guardMiddleware(opts = {}) {
|
|
|
68
92
|
}
|
|
69
93
|
}
|
|
70
94
|
}
|
|
71
|
-
// Dev mode: warn about sensitive fields in request
|
|
95
|
+
// Dev mode: warn about sensitive fields in request with full path
|
|
72
96
|
if (devMode && req.body && typeof req.body === 'object') {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
const warnSensitive = (obj, path) => {
|
|
98
|
+
for (const key of Object.keys(obj)) {
|
|
99
|
+
const fullPath = path ? `${path}.${key}` : key;
|
|
100
|
+
if ((0, security_1.isSensitive)(key, sensitiveFields)) {
|
|
101
|
+
(opts.logger ?? console.warn)(`[payload-guard] ${routeName}: Unexpected field: ${fullPath}`, 'warn');
|
|
102
|
+
}
|
|
103
|
+
if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
|
104
|
+
warnSensitive(obj[key], fullPath);
|
|
105
|
+
}
|
|
76
106
|
}
|
|
77
|
-
}
|
|
107
|
+
};
|
|
108
|
+
warnSensitive(req.body, '');
|
|
78
109
|
}
|
|
79
110
|
// Attach guardJson helper to response
|
|
80
111
|
res.guardJson = function (shape, data) {
|
|
@@ -109,13 +140,18 @@ function guardMiddleware(opts = {}) {
|
|
|
109
140
|
const isStreamLike = data && typeof data.pipe === 'function';
|
|
110
141
|
if (isStreamLike)
|
|
111
142
|
return res.json(data);
|
|
143
|
+
// timing start
|
|
144
|
+
const start = performance.now();
|
|
112
145
|
const filtered = filterFn(data);
|
|
146
|
+
const end = performance.now();
|
|
147
|
+
const duration = (end - start).toFixed(2);
|
|
113
148
|
// metrics
|
|
114
149
|
if (payloadMetrics) {
|
|
115
150
|
try {
|
|
116
151
|
const { metric } = require('../core/perf');
|
|
117
152
|
const m = metric(data, filtered);
|
|
118
|
-
|
|
153
|
+
const reduction = `reduced ${m.formatBefore} -> ${m.formatAfter} (${m.percent}%)`;
|
|
154
|
+
(opts.logger ?? console.info)(`[payload-guard] ${routeName}: ${reduction} in ${duration}ms`, 'info');
|
|
119
155
|
}
|
|
120
156
|
catch { }
|
|
121
157
|
}
|
package/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payload-guard-filter",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Lightweight, zero-dependency shape-based payload filtering and sanitization for Node.js and browser",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
|
-
"module": "dist/index.
|
|
6
|
+
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
-
"import": "./dist/index.
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
11
|
"require": "./dist/index.js",
|
|
12
12
|
"types": "./dist/index.d.ts"
|
|
13
13
|
},
|
|
14
14
|
"./express": {
|
|
15
|
-
"import": "./dist/express.
|
|
15
|
+
"import": "./dist/express.js",
|
|
16
16
|
"require": "./dist/express.js",
|
|
17
17
|
"types": "./dist/express.d.ts"
|
|
18
18
|
},
|
|
19
19
|
"./client": {
|
|
20
|
-
"import": "./dist/client.
|
|
20
|
+
"import": "./dist/client.js",
|
|
21
21
|
"require": "./dist/client.js",
|
|
22
22
|
"types": "./dist/client.d.ts"
|
|
23
23
|
}
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"test:watch": "vitest",
|
|
42
42
|
"test:coverage": "vitest run --coverage",
|
|
43
43
|
"lint": "eslint src --ext .ts",
|
|
44
|
-
"format": "prettier --write \"src/**/*.ts\""
|
|
44
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
45
|
+
"benchmark": "npx ts-node benchmarks/run.ts"
|
|
45
46
|
},
|
|
46
47
|
"keywords": [
|
|
47
48
|
"payload",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
],
|
|
60
61
|
"author": "",
|
|
61
62
|
"license": "MIT",
|
|
63
|
+
"sideEffects": false,
|
|
62
64
|
"engines": {
|
|
63
65
|
"node": ">=18.0.0"
|
|
64
66
|
},
|