@zero-server/middleware 0.9.1 → 0.9.2

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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @module helmet
3
+ * @description Security headers middleware.
4
+ * Sets common security-related HTTP response headers to help
5
+ * protect against well-known web vulnerabilities (XSS, clickjacking,
6
+ * MIME sniffing, etc.).
7
+ *
8
+ * Inspired by the `helmet` npm package but zero-dependency.
9
+ */
10
+
11
+ /**
12
+ * Create a security headers middleware.
13
+ *
14
+ * @param {object} [opts] - Configuration options.
15
+ * @param {object|false} [opts.contentSecurityPolicy] - CSP directive object or `false` to disable.
16
+ * @param {boolean} [opts.crossOriginEmbedderPolicy=false] - Set COEP header.
17
+ * @param {string|false} [opts.crossOriginOpenerPolicy='same-origin'] - COOP value.
18
+ * @param {string|false} [opts.crossOriginResourcePolicy='same-origin'] - CORP value.
19
+ * @param {boolean} [opts.dnsPrefetchControl=true] - Set X-DNS-Prefetch-Control: off.
20
+ * @param {string|false} [opts.frameguard='deny'] - X-Frame-Options value ('deny' | 'sameorigin').
21
+ * @param {boolean} [opts.hidePoweredBy=true] - Remove X-Powered-By header.
22
+ * @param {boolean|number}[opts.hsts=true] - Set Strict-Transport-Security.
23
+ * @param {number} [opts.hstsMaxAge=15552000] - HSTS max-age in seconds (default ~180 days).
24
+ * @param {boolean} [opts.hstsIncludeSubDomains=true] - HSTS includeSubDomains directive.
25
+ * @param {boolean} [opts.hstsPreload=false] - HSTS preload directive.
26
+ * @param {boolean} [opts.ieNoOpen=true] - Set X-Download-Options: noopen.
27
+ * @param {boolean} [opts.noSniff=true] - Set X-Content-Type-Options: nosniff.
28
+ * @param {string|false} [opts.permittedCrossDomainPolicies='none'] - X-Permitted-Cross-Domain-Policies.
29
+ * @param {string|false} [opts.referrerPolicy='no-referrer'] - Referrer-Policy value.
30
+ * @param {boolean} [opts.xssFilter=false] - Set X-XSS-Protection (legacy, off by default).
31
+ * @returns {Function} Middleware `(req, res, next) => void`.
32
+ *
33
+ * @example
34
+ * app.use(helmet());
35
+ * app.use(helmet({ frameguard: 'sameorigin', hsts: false }));
36
+ * app.use(helmet({
37
+ * contentSecurityPolicy: {
38
+ * directives: {
39
+ * defaultSrc: ["'self'"],
40
+ * scriptSrc: ["'self'", "'unsafe-inline'"],
41
+ * styleSrc: ["'self'", "'unsafe-inline'"],
42
+ * imgSrc: ["'self'", "data:", "https:"],
43
+ * }
44
+ * }
45
+ * }));
46
+ */
47
+ function helmet(opts = {})
48
+ {
49
+ return (req, res, next) =>
50
+ {
51
+ const raw = res.raw || res;
52
+
53
+ // -- Content-Security-Policy --------------------
54
+ if (opts.contentSecurityPolicy !== false)
55
+ {
56
+ const csp = opts.contentSecurityPolicy || {};
57
+ const directives = csp.directives || {
58
+ defaultSrc: ["'self'"],
59
+ baseUri: ["'self'"],
60
+ fontSrc: ["'self'", 'https:', 'data:'],
61
+ formAction: ["'self'"],
62
+ frameAncestors: ["'self'"],
63
+ imgSrc: ["'self'", 'data:'],
64
+ objectSrc: ["'none'"],
65
+ scriptSrc: ["'self'"],
66
+ scriptSrcAttr: ["'none'"],
67
+ styleSrc: ["'self'", "'unsafe-inline'"],
68
+ upgradeInsecureRequests: [],
69
+ };
70
+
71
+ const cspString = Object.entries(directives)
72
+ .map(([key, values]) =>
73
+ {
74
+ const directive = key.replace(/([A-Z])/g, '-$1').toLowerCase();
75
+ if (Array.isArray(values) && values.length === 0) return directive;
76
+ return `${directive} ${Array.isArray(values) ? values.join(' ') : values}`;
77
+ })
78
+ .join('; ');
79
+
80
+ if (cspString)
81
+ {
82
+ try { raw.setHeader('Content-Security-Policy', cspString); } catch (e) { }
83
+ }
84
+ }
85
+
86
+ // -- Cross-Origin-Embedder-Policy ---------------
87
+ if (opts.crossOriginEmbedderPolicy)
88
+ {
89
+ try { raw.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); } catch (e) { }
90
+ }
91
+
92
+ // -- Cross-Origin-Opener-Policy -----------------
93
+ if (opts.crossOriginOpenerPolicy !== false)
94
+ {
95
+ const coop = opts.crossOriginOpenerPolicy || 'same-origin';
96
+ try { raw.setHeader('Cross-Origin-Opener-Policy', coop); } catch (e) { }
97
+ }
98
+
99
+ // -- Cross-Origin-Resource-Policy ---------------
100
+ if (opts.crossOriginResourcePolicy !== false)
101
+ {
102
+ const corp = opts.crossOriginResourcePolicy || 'same-origin';
103
+ try { raw.setHeader('Cross-Origin-Resource-Policy', corp); } catch (e) { }
104
+ }
105
+
106
+ // -- DNS Prefetch Control -----------------------
107
+ if (opts.dnsPrefetchControl !== false)
108
+ {
109
+ try { raw.setHeader('X-DNS-Prefetch-Control', 'off'); } catch (e) { }
110
+ }
111
+
112
+ // -- Frameguard (X-Frame-Options) ---------------
113
+ if (opts.frameguard !== false)
114
+ {
115
+ const frame = (opts.frameguard || 'deny').toUpperCase();
116
+ try { raw.setHeader('X-Frame-Options', frame); } catch (e) { }
117
+ }
118
+
119
+ // -- Hide X-Powered-By -------------------------
120
+ if (opts.hidePoweredBy !== false)
121
+ {
122
+ try { raw.removeHeader('X-Powered-By'); } catch (e) { }
123
+ }
124
+
125
+ // -- HSTS ---------------------------------------
126
+ if (opts.hsts !== false)
127
+ {
128
+ const maxAge = opts.hstsMaxAge || 15552000;
129
+ let hstsValue = `max-age=${maxAge}`;
130
+ if (opts.hstsIncludeSubDomains !== false) hstsValue += '; includeSubDomains';
131
+ if (opts.hstsPreload) hstsValue += '; preload';
132
+ try { raw.setHeader('Strict-Transport-Security', hstsValue); } catch (e) { }
133
+ }
134
+
135
+ // -- IE No Open --------------------------------
136
+ if (opts.ieNoOpen !== false)
137
+ {
138
+ try { raw.setHeader('X-Download-Options', 'noopen'); } catch (e) { }
139
+ }
140
+
141
+ // -- No Sniff -----------------------------------
142
+ if (opts.noSniff !== false)
143
+ {
144
+ try { raw.setHeader('X-Content-Type-Options', 'nosniff'); } catch (e) { }
145
+ }
146
+
147
+ // -- Permitted Cross Domain Policies ------------
148
+ if (opts.permittedCrossDomainPolicies !== false)
149
+ {
150
+ const pcdp = opts.permittedCrossDomainPolicies || 'none';
151
+ try { raw.setHeader('X-Permitted-Cross-Domain-Policies', pcdp); } catch (e) { }
152
+ }
153
+
154
+ // -- Referrer Policy ----------------------------
155
+ if (opts.referrerPolicy !== false)
156
+ {
157
+ const rp = opts.referrerPolicy || 'no-referrer';
158
+ try { raw.setHeader('Referrer-Policy', rp); } catch (e) { }
159
+ }
160
+
161
+ // -- XSS Filter (legacy) -----------------------
162
+ if (opts.xssFilter)
163
+ {
164
+ try { raw.setHeader('X-XSS-Protection', '1; mode=block'); } catch (e) { }
165
+ }
166
+ else
167
+ {
168
+ // Modern best practice: disable legacy XSS auditor
169
+ try { raw.setHeader('X-XSS-Protection', '0'); } catch (e) { }
170
+ }
171
+
172
+ next();
173
+ };
174
+ }
175
+
176
+ module.exports = helmet;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @module middleware
3
+ * @description Built-in middleware for zero-server.
4
+ * Re-exports all middleware.
5
+ */
6
+ const cors = require('./cors');
7
+ const logger = require('./logger');
8
+ const rateLimit = require('./rateLimit');
9
+ const compress = require('./compress');
10
+ const serveStatic = require('./static');
11
+ const helmet = require('./helmet');
12
+ const timeout = require('./timeout');
13
+ const requestId = require('./requestId');
14
+ const cookieParser = require('./cookieParser');
15
+ const errorHandler = require('./errorHandler');
16
+ const csrf = require('./csrf');
17
+ const validate = require('./validator');
18
+
19
+ module.exports = { cors, logger, rateLimit, compress, static: serveStatic, helmet, timeout, requestId, cookieParser, errorHandler, csrf, validate };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @module middleware/logger
3
+ * @description Simple request-logging middleware.
4
+ * Logs method, url, status code, and response time.
5
+ *
6
+ * @param {object} [opts] - Configuration options.
7
+ * @param {function} [opts.logger] - Custom log function (default: console.log).
8
+ * @param {boolean} [opts.colors] - Colorize output (default: true when TTY).
9
+ * @param {string} [opts.format] - 'tiny' | 'short' | 'dev' (default: 'dev').
10
+ * @returns {Function} Middleware `(req, res, next) => void`.
11
+ *
12
+ * @example
13
+ * app.use(logger()); // default 'dev' format
14
+ * app.use(logger({ format: 'tiny' })); // minimal output
15
+ * app.use(logger({ colors: false, logger: msg => fs.appendFileSync('access.log', msg + '\n') }));
16
+ */
17
+ function logger(opts = {})
18
+ {
19
+ const log = typeof opts.logger === 'function' ? opts.logger : console.log;
20
+ const useColors = opts.colors !== undefined ? opts.colors : (process.stdout.isTTY || false);
21
+ const format = opts.format || 'dev';
22
+
23
+ // ANSI color helpers
24
+ const c = {
25
+ reset: useColors ? '\x1b[0m' : '',
26
+ green: useColors ? '\x1b[32m' : '',
27
+ yellow: useColors ? '\x1b[33m' : '',
28
+ red: useColors ? '\x1b[31m' : '',
29
+ cyan: useColors ? '\x1b[36m' : '',
30
+ dim: useColors ? '\x1b[2m' : '',
31
+ };
32
+
33
+ function statusColor(code)
34
+ {
35
+ if (code >= 500) return c.red;
36
+ if (code >= 400) return c.yellow;
37
+ if (code >= 300) return c.cyan;
38
+ return c.green;
39
+ }
40
+
41
+ return (req, res, next) =>
42
+ {
43
+ const start = Date.now();
44
+
45
+ // Hook into the raw response 'finish' event
46
+ const raw = res.raw;
47
+ const onFinish = () =>
48
+ {
49
+ raw.removeListener('finish', onFinish);
50
+ const ms = Date.now() - start;
51
+ const status = raw.statusCode || res._status;
52
+ const sc = statusColor(status);
53
+
54
+ if (format === 'tiny')
55
+ {
56
+ log(`${req.method} ${req.url} ${status} - ${ms}ms`);
57
+ }
58
+ else if (format === 'short')
59
+ {
60
+ log(`${req.ip || '-'} ${req.method} ${req.url} ${sc}${status}${c.reset} ${ms}ms`);
61
+ }
62
+ else
63
+ {
64
+ // dev format
65
+ log(` ${c.dim}${req.method}${c.reset} ${req.url} ${sc}${status}${c.reset} ${c.dim}${ms}ms${c.reset}`);
66
+ }
67
+ };
68
+ raw.on('finish', onFinish);
69
+
70
+ next();
71
+ };
72
+ }
73
+
74
+ module.exports = logger;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @module middleware/rateLimit
3
+ * @description In-memory rate-limiting middleware.
4
+ * Limits requests per IP address within a fixed time window.
5
+ */
6
+ const log = require('../debug')('zero:rateLimit');
7
+
8
+ /**
9
+ * Create a rate-limiting middleware.
10
+ *
11
+ * @param {object} [opts] - Configuration options.
12
+ * @param {number} [opts.windowMs=60000] - Time window in milliseconds.
13
+ * @param {number} [opts.max=100] - Maximum requests per window per IP.
14
+ * @param {string} [opts.message] - Custom error message.
15
+ * @param {number} [opts.statusCode=429] - HTTP status for rate-limited responses.
16
+ * @param {function} [opts.keyGenerator] - (req) => string; custom key extraction (default: req.ip).
17
+ * @param {function} [opts.skip] - (req) => boolean; return true to skip rate limiting.
18
+ * @param {function} [opts.handler] - (req, res) => void; custom handler for rate-limited requests.
19
+ * @returns {Function} Middleware `(req, res, next) => void`.
20
+ *
21
+ * @example
22
+ * app.use(rateLimit()); // 100 req/min per IP
23
+ * app.use(rateLimit({ windowMs: 15 * 60000, max: 50 })); // 50 req per 15 min
24
+ * app.use(rateLimit({
25
+ * max: 10,
26
+ * keyGenerator: req => req.headers['x-api-key'],
27
+ * skip: req => req.path === '/health',
28
+ * }));
29
+ */
30
+ function rateLimit(opts = {})
31
+ {
32
+ const windowMs = opts.windowMs || 60_000;
33
+ const max = opts.max || 100;
34
+ const statusCode = opts.statusCode || 429;
35
+ const message = opts.message || 'Too many requests, please try again later.';
36
+ const keyGenerator = typeof opts.keyGenerator === 'function' ? opts.keyGenerator : (req) => req.ip || 'unknown';
37
+ const skipFn = typeof opts.skip === 'function' ? opts.skip : null;
38
+ const handlerFn = typeof opts.handler === 'function' ? opts.handler : null;
39
+
40
+ const hits = new Map(); // key → { count, resetTime }
41
+
42
+ // Periodic cleanup to prevent memory leaks
43
+ const cleanupInterval = setInterval(() =>
44
+ {
45
+ const now = Date.now();
46
+ for (const [key, entry] of hits)
47
+ {
48
+ if (now >= entry.resetTime) hits.delete(key);
49
+ }
50
+ }, windowMs);
51
+ if (cleanupInterval.unref) cleanupInterval.unref();
52
+
53
+ return (req, res, next) =>
54
+ {
55
+ // Allow skipping rate limit for certain requests
56
+ if (skipFn && skipFn(req)) return next();
57
+
58
+ const key = keyGenerator(req);
59
+ const now = Date.now();
60
+ let entry = hits.get(key);
61
+
62
+ if (!entry || now >= entry.resetTime)
63
+ {
64
+ entry = { count: 0, resetTime: now + windowMs };
65
+ hits.set(key, entry);
66
+ }
67
+
68
+ entry.count++;
69
+
70
+ // Set rate-limit headers
71
+ const remaining = Math.max(0, max - entry.count);
72
+ res.set('X-RateLimit-Limit', String(max));
73
+ res.set('X-RateLimit-Remaining', String(remaining));
74
+ res.set('X-RateLimit-Reset', String(Math.ceil(entry.resetTime / 1000)));
75
+
76
+ if (entry.count > max)
77
+ {
78
+ log.warn('rate limit exceeded for %s', key);
79
+ res.set('Retry-After', String(Math.ceil(windowMs / 1000)));
80
+ if (handlerFn) return handlerFn(req, res);
81
+ return res.status(statusCode).json({ error: message });
82
+ }
83
+
84
+ next();
85
+ };
86
+ }
87
+
88
+ module.exports = rateLimit;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @module requestId
3
+ * @description Request ID middleware.
4
+ * Assigns a unique identifier to each incoming request for
5
+ * tracing and debugging. Sets the ID on both the request
6
+ * object and as a response header.
7
+ */
8
+ const crypto = require('crypto');
9
+
10
+ /**
11
+ * Create a request ID middleware.
12
+ *
13
+ * @param {object} [opts] - Configuration options.
14
+ * @param {string} [opts.header='X-Request-Id'] - Response header name.
15
+ * @param {Function} [opts.generator] - Custom ID generator `() => string`.
16
+ * @param {boolean} [opts.trustProxy=false] - Trust incoming X-Request-Id header from proxy.
17
+ * @returns {Function} Middleware `(req, res, next) => void`.
18
+ *
19
+ * @example
20
+ * app.use(requestId());
21
+ * app.get('/', (req, res) => {
22
+ * console.log(req.id); // e.g. '7f3a2b1c-...'
23
+ * });
24
+ */
25
+ function requestId(opts = {})
26
+ {
27
+ const headerName = opts.header || 'X-Request-Id';
28
+ const trustProxy = !!opts.trustProxy;
29
+ const generator = typeof opts.generator === 'function'
30
+ ? opts.generator
31
+ : () => crypto.randomUUID();
32
+
33
+ return (req, res, next) =>
34
+ {
35
+ let id;
36
+
37
+ if (trustProxy)
38
+ {
39
+ const existing = req.headers[headerName.toLowerCase()];
40
+ if (existing && typeof existing === 'string' && existing.length <= 128)
41
+ {
42
+ id = existing;
43
+ }
44
+ }
45
+
46
+ if (!id) id = generator();
47
+
48
+ req.id = id;
49
+ res.set(headerName, id);
50
+ next();
51
+ };
52
+ }
53
+
54
+ module.exports = requestId;