woodland 22.0.6 → 22.1.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.cjs +1 -1
- package/dist/woodland.cjs +139 -91
- package/dist/woodland.js +139 -91
- package/package.json +2 -2
- package/types/woodland.d.ts +3 -0
package/dist/cli.cjs
CHANGED
package/dist/woodland.cjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @copyright 2026 Jason Mulligan <jason.mulligan@avoidwork.com>
|
|
5
5
|
* @license BSD-3-Clause
|
|
6
|
-
* @version 22.0
|
|
6
|
+
* @version 22.1.0
|
|
7
7
|
*/
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
@@ -53,6 +53,7 @@ const INT_400 = 400;
|
|
|
53
53
|
const INT_403 = 403;
|
|
54
54
|
const INT_404 = 404;
|
|
55
55
|
const INT_405 = 405;
|
|
56
|
+
const INT_413 = 413;
|
|
56
57
|
const INT_416 = 416;
|
|
57
58
|
const INT_500 = 500;
|
|
58
59
|
|
|
@@ -109,6 +110,7 @@ const INT_60 = 60;
|
|
|
109
110
|
const INT_255 = 255;
|
|
110
111
|
const INT_1e3 = 1e3;
|
|
111
112
|
const INT_1e4 = 1e4;
|
|
113
|
+
const INT_1e6$1 = 1e6;
|
|
112
114
|
const INT_8000 = 8000;
|
|
113
115
|
const INT_NEG_1 = -1;
|
|
114
116
|
|
|
@@ -227,6 +229,7 @@ const BYTES_SPACE = "bytes ";
|
|
|
227
229
|
// EVENT & STREAM CONSTANTS
|
|
228
230
|
// =============================================================================
|
|
229
231
|
const EVT_CLOSE = "close";
|
|
232
|
+
const EVT_DATA = "data";
|
|
230
233
|
const EVT_FINISH = "finish";
|
|
231
234
|
const EVT_STREAM = "stream";
|
|
232
235
|
const EVT_CONNECT = "connect";
|
|
@@ -246,6 +249,7 @@ const HTTP_VERSION = "HTTP/1.1";
|
|
|
246
249
|
const ITEM = "item";
|
|
247
250
|
const NOTICE = "notice";
|
|
248
251
|
const SHORT = "short";
|
|
252
|
+
const RESPONSE_TIME_UNIT = " ms";
|
|
249
253
|
const TO_STRING = "toString";
|
|
250
254
|
const TRUE = "true";
|
|
251
255
|
const WARN = "warn";
|
|
@@ -724,17 +728,20 @@ function escapeHtml(str = EMPTY) {
|
|
|
724
728
|
* @param {Object} req - Request object
|
|
725
729
|
* @param {Object} res - Response object
|
|
726
730
|
* @param {EventEmitter} emitter - EventEmitter for error events
|
|
727
|
-
* @
|
|
731
|
+
* @param {boolean} [exposeErrorMessages=false] - Expose internal error messages to clients
|
|
728
732
|
*/
|
|
729
|
-
function createErrorHandler(req, res, emitter) {
|
|
730
|
-
return (
|
|
731
|
-
error(req, res,
|
|
733
|
+
function createErrorHandler(req, res, emitter, exposeErrorMessages = false) {
|
|
734
|
+
return (inputStatus = res.statusCode, body) => {
|
|
735
|
+
error(req, res, inputStatus);
|
|
732
736
|
const err = body instanceof Error ? body : new Error(body ?? getStatusText(res.statusCode));
|
|
733
737
|
emitter.emit(EVT_ERROR, req, res, err);
|
|
734
738
|
if (req.headers) {
|
|
735
739
|
delete req.headers.range;
|
|
736
740
|
}
|
|
737
|
-
|
|
741
|
+
const message = exposeErrorMessages
|
|
742
|
+
? err.message || getStatusText(res.statusCode)
|
|
743
|
+
: getStatusText(res.statusCode);
|
|
744
|
+
res.send(message);
|
|
738
745
|
};
|
|
739
746
|
}
|
|
740
747
|
|
|
@@ -1350,13 +1357,16 @@ function registerMiddleware(middleware, ignored, methods, rpath, ...fn) {
|
|
|
1350
1357
|
|
|
1351
1358
|
const DEFAULTS = {
|
|
1352
1359
|
autoIndex: false,
|
|
1360
|
+
bodyLimit: INT_10 * INT_1e6$1,
|
|
1353
1361
|
cacheSize: INT_1e3,
|
|
1354
1362
|
cacheTTL: INT_1e4,
|
|
1355
1363
|
charset: UTF_8,
|
|
1356
1364
|
corsExpose: EMPTY,
|
|
1357
1365
|
defaultHeaders: {},
|
|
1358
1366
|
digit: INT_3,
|
|
1367
|
+
disableTrace: true,
|
|
1359
1368
|
etags: true,
|
|
1369
|
+
exposeErrorMessages: false,
|
|
1360
1370
|
indexes: [INDEX_HTM, INDEX_HTML],
|
|
1361
1371
|
logging: {},
|
|
1362
1372
|
origins: [],
|
|
@@ -1369,13 +1379,16 @@ const CONFIG_SCHEMA = {
|
|
|
1369
1379
|
type: OBJECT,
|
|
1370
1380
|
properties: {
|
|
1371
1381
|
autoIndex: { type: BOOLEAN },
|
|
1382
|
+
bodyLimit: { type: NUMBER, minimum: INT_1 },
|
|
1372
1383
|
cacheSize: { type: NUMBER, minimum: INT_1 },
|
|
1373
1384
|
cacheTTL: { type: NUMBER, minimum: INT_1 },
|
|
1374
1385
|
charset: { type: STRING },
|
|
1375
1386
|
corsExpose: { type: STRING },
|
|
1376
1387
|
defaultHeaders: { type: OBJECT },
|
|
1377
1388
|
digit: { type: NUMBER, minimum: INT_1, maximum: INT_10 },
|
|
1389
|
+
disableTrace: { type: BOOLEAN },
|
|
1378
1390
|
etags: { type: BOOLEAN },
|
|
1391
|
+
exposeErrorMessages: { type: BOOLEAN },
|
|
1379
1392
|
indexes: { type: ARRAY, items: { type: STRING } },
|
|
1380
1393
|
logging: { type: OBJECT },
|
|
1381
1394
|
origins: { type: ARRAY, items: { type: STRING } },
|
|
@@ -1851,12 +1864,15 @@ class Woodland extends node_events.EventEmitter {
|
|
|
1851
1864
|
#charset;
|
|
1852
1865
|
#corsExpose;
|
|
1853
1866
|
#defaultHeaders;
|
|
1867
|
+
#disableTrace;
|
|
1854
1868
|
#digit;
|
|
1855
1869
|
#etags;
|
|
1870
|
+
#exposeErrorMessages;
|
|
1856
1871
|
#indexes;
|
|
1857
1872
|
#logging;
|
|
1858
1873
|
#origins;
|
|
1859
1874
|
#time;
|
|
1875
|
+
#bodyLimit;
|
|
1860
1876
|
#cache;
|
|
1861
1877
|
#methods;
|
|
1862
1878
|
#logger;
|
|
@@ -1866,14 +1882,17 @@ class Woodland extends node_events.EventEmitter {
|
|
|
1866
1882
|
/**
|
|
1867
1883
|
* Creates a new Woodland instance
|
|
1868
1884
|
* @param {Object} [config={}] - Configuration object
|
|
1885
|
+
* @param {number} [config.bodyLimit=10000000] - Max request body size in bytes
|
|
1869
1886
|
* @param {boolean} [config.autoIndex=false] - Enable automatic directory indexing
|
|
1870
1887
|
* @param {number} [config.cacheSize=1000] - Size of internal cache
|
|
1871
1888
|
* @param {number} [config.cacheTTL=10000] - Cache TTL in milliseconds
|
|
1872
1889
|
* @param {string} [config.charset='utf-8'] - Default charset
|
|
1873
1890
|
* @param {string} [config.corsExpose=''] - CORS expose headers
|
|
1891
|
+
* @param {boolean} [config.disableTrace=true] - Disable TRACE method (XST vulnerability prevention)
|
|
1874
1892
|
* @param {Object} [config.defaultHeaders={}] - Default headers to set
|
|
1875
1893
|
* @param {number} [config.digit=3] - Digit precision for timing
|
|
1876
1894
|
* @param {boolean} [config.etags=true] - Enable ETags
|
|
1895
|
+
* @param {boolean} [config.exposeErrorMessages=false] - Expose internal error messages to clients
|
|
1877
1896
|
* @param {Array} [config.indexes=['index.htm','index.html']] - Index files
|
|
1878
1897
|
* @param {Object} [config.logging={}] - Logging configuration
|
|
1879
1898
|
* @param {Array} [config.origins=[]] - Allowed CORS origins
|
|
@@ -1886,13 +1905,16 @@ class Woodland extends node_events.EventEmitter {
|
|
|
1886
1905
|
const validated = validateConfig(config);
|
|
1887
1906
|
|
|
1888
1907
|
this.#autoIndex = validated.autoIndex;
|
|
1908
|
+
this.#bodyLimit = validated.bodyLimit;
|
|
1889
1909
|
this.#charset = validated.charset;
|
|
1890
1910
|
this.#corsExpose = validated.corsExpose;
|
|
1891
1911
|
this.#defaultHeaders = this.#buildFinalHeaders(validated.defaultHeaders, validated.silent);
|
|
1912
|
+
this.#disableTrace = validated.disableTrace;
|
|
1892
1913
|
this.#digit = validated.digit;
|
|
1893
1914
|
this.#etags = validated.etags
|
|
1894
1915
|
? Object.freeze(tinyEtag.etag({ cacheSize: validated.cacheSize, cacheTTL: validated.cacheTTL }))
|
|
1895
1916
|
: null;
|
|
1917
|
+
this.#exposeErrorMessages = validated.exposeErrorMessages;
|
|
1896
1918
|
this.#indexes = [...validated.indexes];
|
|
1897
1919
|
this.#logging = Object.freeze(validateLogging(validated.logging));
|
|
1898
1920
|
this.#origins = new Set(validated.origins);
|
|
@@ -1905,6 +1927,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
1905
1927
|
|
|
1906
1928
|
this.#setupMiddleware();
|
|
1907
1929
|
this.#setupErrorHandling();
|
|
1930
|
+
this.#setupBodyLimit();
|
|
1908
1931
|
}
|
|
1909
1932
|
|
|
1910
1933
|
/**
|
|
@@ -1977,15 +2000,38 @@ class Woodland extends node_events.EventEmitter {
|
|
|
1977
2000
|
);
|
|
1978
2001
|
}
|
|
1979
2002
|
|
|
2003
|
+
/**
|
|
2004
|
+
* Sets up body size limit middleware
|
|
2005
|
+
*/
|
|
2006
|
+
#setupBodyLimit() {
|
|
2007
|
+
const maxLimit = this.#bodyLimit;
|
|
2008
|
+
const handler = (req, res, next) => {
|
|
2009
|
+
let size = INT_0;
|
|
2010
|
+
/* node:coverage ignore next 1 */
|
|
2011
|
+
if (typeof req.on !== FUNCTION) {
|
|
2012
|
+
return next();
|
|
2013
|
+
}
|
|
2014
|
+
req.on(EVT_DATA, (chunk) => {
|
|
2015
|
+
size += chunk.length;
|
|
2016
|
+
/* node:coverage ignore next 3 */
|
|
2017
|
+
if (size > maxLimit) {
|
|
2018
|
+
req.destroy();
|
|
2019
|
+
res.error(INT_413);
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
next();
|
|
2023
|
+
};
|
|
2024
|
+
this.always(handler);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
1980
2027
|
/**
|
|
1981
2028
|
* Determines allowed methods for a URI
|
|
1982
2029
|
* @param {string} uri - URI to check
|
|
1983
2030
|
* @param {boolean} [override=false] - Override cache
|
|
1984
|
-
* @param {boolean} [isCorsRequest=false] - Whether this is a CORS request
|
|
1985
2031
|
* @returns {string} Comma-separated list of allowed methods
|
|
1986
2032
|
*/
|
|
1987
|
-
#allows(uri, override = false
|
|
1988
|
-
const key =
|
|
2033
|
+
#allows(uri, override = false) {
|
|
2034
|
+
const key = `${DELIMITER}${uri}`;
|
|
1989
2035
|
let result = !override ? this.#cache.get(key) : void 0;
|
|
1990
2036
|
|
|
1991
2037
|
if (override || result === void 0) {
|
|
@@ -1997,7 +2043,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
1997
2043
|
}
|
|
1998
2044
|
}
|
|
1999
2045
|
|
|
2000
|
-
const list = this.#buildAllowedList(methodSet
|
|
2046
|
+
const list = this.#buildAllowedList(methodSet);
|
|
2001
2047
|
result = list.sort().join(COMMA_SPACE);
|
|
2002
2048
|
this.#cache.set(key, result);
|
|
2003
2049
|
this.#logger.log(
|
|
@@ -2009,12 +2055,11 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2009
2055
|
}
|
|
2010
2056
|
|
|
2011
2057
|
/**
|
|
2012
|
-
* Builds the list of allowed methods including implicit HEAD
|
|
2058
|
+
* Builds the list of allowed methods including implicit HEAD, OPTIONS
|
|
2013
2059
|
* @param {Set} methodSet - Set of explicitly registered methods
|
|
2014
|
-
* @param {boolean} isCorsRequest - Whether this is a CORS request
|
|
2015
2060
|
* @returns {Array} Array of allowed methods
|
|
2016
2061
|
*/
|
|
2017
|
-
#buildAllowedList(methodSet
|
|
2062
|
+
#buildAllowedList(methodSet) {
|
|
2018
2063
|
const list = [...methodSet];
|
|
2019
2064
|
|
|
2020
2065
|
if (list.length > INT_0) {
|
|
@@ -2022,8 +2067,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2022
2067
|
list.push(HEAD);
|
|
2023
2068
|
}
|
|
2024
2069
|
|
|
2025
|
-
|
|
2026
|
-
if (!methodSet.has(OPTIONS) && isCorsRequest) {
|
|
2070
|
+
if (!methodSet.has(OPTIONS)) {
|
|
2027
2071
|
list.push(OPTIONS);
|
|
2028
2072
|
}
|
|
2029
2073
|
}
|
|
@@ -2046,13 +2090,23 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2046
2090
|
return this.use(...args, WILDCARD);
|
|
2047
2091
|
}
|
|
2048
2092
|
|
|
2093
|
+
/**
|
|
2094
|
+
* Registers middleware for a given HTTP method
|
|
2095
|
+
* @param {string} method - HTTP method name
|
|
2096
|
+
* @param {...*} args - Middleware function(s)
|
|
2097
|
+
* @returns {Woodland} Returns self for chaining
|
|
2098
|
+
*/
|
|
2099
|
+
#registerMethod(method, ...args) {
|
|
2100
|
+
return this.use(...args, method);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2049
2103
|
/**
|
|
2050
2104
|
* Registers CONNECT middleware
|
|
2051
2105
|
* @param {...*} args - Middleware function(s)
|
|
2052
2106
|
* @returns {Woodland} Returns self for chaining
|
|
2053
2107
|
*/
|
|
2054
2108
|
connect(...args) {
|
|
2055
|
-
return this
|
|
2109
|
+
return this.#registerMethod(CONNECT, ...args);
|
|
2056
2110
|
}
|
|
2057
2111
|
|
|
2058
2112
|
/**
|
|
@@ -2074,8 +2128,20 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2074
2128
|
req.params = {};
|
|
2075
2129
|
req.valid = true;
|
|
2076
2130
|
|
|
2077
|
-
const allowString = this.#allows(parsed.pathname
|
|
2078
|
-
const headersBatch =
|
|
2131
|
+
const allowString = this.#allows(parsed.pathname);
|
|
2132
|
+
const headersBatch = Object.create(null);
|
|
2133
|
+
headersBatch[ALLOW] = allowString;
|
|
2134
|
+
headersBatch[X_CONTENT_TYPE_OPTIONS] = NO_SNIFF;
|
|
2135
|
+
|
|
2136
|
+
const defaultHeaders = this.#defaultHeaders;
|
|
2137
|
+
const headerCount = defaultHeaders.length;
|
|
2138
|
+
for (let i = INT_0; i < headerCount; i++) {
|
|
2139
|
+
const [key, value] = defaultHeaders[i];
|
|
2140
|
+
if (typeof key === STRING && (typeof value === STRING || typeof value === NUMBER)) {
|
|
2141
|
+
headersBatch[key] = value;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2079
2145
|
req.allow = allowString;
|
|
2080
2146
|
|
|
2081
2147
|
if (timing) {
|
|
@@ -2092,28 +2158,6 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2092
2158
|
);
|
|
2093
2159
|
}
|
|
2094
2160
|
|
|
2095
|
-
/**
|
|
2096
|
-
* Builds default headers batch
|
|
2097
|
-
* @param {string} allowString - Allow header value
|
|
2098
|
-
* @returns {Object} Headers batch object
|
|
2099
|
-
*/
|
|
2100
|
-
#buildDefaultHeaders(allowString) {
|
|
2101
|
-
const headersBatch = Object.create(null);
|
|
2102
|
-
headersBatch[ALLOW] = allowString;
|
|
2103
|
-
headersBatch[X_CONTENT_TYPE_OPTIONS] = NO_SNIFF;
|
|
2104
|
-
|
|
2105
|
-
const defaultHeaders = this.#defaultHeaders;
|
|
2106
|
-
const headerCount = defaultHeaders.length;
|
|
2107
|
-
for (let i = INT_0; i < headerCount; i++) {
|
|
2108
|
-
const [key, value] = defaultHeaders[i];
|
|
2109
|
-
if (typeof key === STRING && (typeof value === STRING || typeof value === NUMBER)) {
|
|
2110
|
-
headersBatch[key] = value;
|
|
2111
|
-
}
|
|
2112
|
-
}
|
|
2113
|
-
|
|
2114
|
-
return headersBatch;
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
2161
|
/**
|
|
2118
2162
|
* Decorates response object with methods and event handlers
|
|
2119
2163
|
* @param {Object} res - HTTP response object
|
|
@@ -2122,7 +2166,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2122
2166
|
*/
|
|
2123
2167
|
#decorateResponse(res, req, headersBatch) {
|
|
2124
2168
|
res.locals = {};
|
|
2125
|
-
res.error = createErrorHandler(req, res, this);
|
|
2169
|
+
res.error = createErrorHandler(req, res, this, this.#exposeErrorMessages);
|
|
2126
2170
|
res.header = res.setHeader;
|
|
2127
2171
|
res.json = createJsonHandler(res);
|
|
2128
2172
|
res.redirect = createRedirectHandler(res);
|
|
@@ -2141,8 +2185,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2141
2185
|
*/
|
|
2142
2186
|
#addCorsHeaders(req, headersBatch) {
|
|
2143
2187
|
const origin = req.headers.origin;
|
|
2144
|
-
const
|
|
2145
|
-
const originAllowed = this.#origins.has(origin);
|
|
2188
|
+
const originAllowed = this.#isSafeOrigin(origin) && this.#origins.has(origin);
|
|
2146
2189
|
const hasWildcard = this.#origins.has(WILDCARD);
|
|
2147
2190
|
|
|
2148
2191
|
/* node:coverage ignore next 9 */
|
|
@@ -2151,21 +2194,44 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2151
2194
|
headersBatch[TIMING_ALLOW_ORIGIN] = origin;
|
|
2152
2195
|
headersBatch[ACCESS_CONTROL_ALLOW_CREDENTIALS] = TRUE;
|
|
2153
2196
|
headersBatch[ACCESS_CONTROL_ALLOW_METHODS] = req.allow;
|
|
2154
|
-
|
|
2155
|
-
if (corsHeaders !== void 0) {
|
|
2156
|
-
headersBatch[
|
|
2157
|
-
req.method === OPTIONS ? ACCESS_CONTROL_ALLOW_HEADERS : ACCESS_CONTROL_EXPOSE_HEADERS
|
|
2158
|
-
] = corsHeaders;
|
|
2159
|
-
}
|
|
2160
2197
|
} else if (hasWildcard) {
|
|
2161
2198
|
headersBatch[ACCESS_CONTROL_ALLOW_ORIGIN] = WILDCARD;
|
|
2162
2199
|
headersBatch[ACCESS_CONTROL_ALLOW_METHODS] = req.allow;
|
|
2200
|
+
}
|
|
2163
2201
|
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2202
|
+
const corsHeaders = req.headers[ACCESS_CONTROL_REQUEST_HEADERS] ?? this.#corsExpose;
|
|
2203
|
+
this.#setCorsAllowAndExposeHeaders(headersBatch, req, corsHeaders);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
/**
|
|
2207
|
+
* Validates origin header for safety
|
|
2208
|
+
* @param {string} origin - Origin header value
|
|
2209
|
+
* @returns {boolean} True if origin is safe
|
|
2210
|
+
*/
|
|
2211
|
+
#isSafeOrigin(origin) {
|
|
2212
|
+
if (!origin || typeof origin !== STRING) {
|
|
2213
|
+
return false;
|
|
2214
|
+
}
|
|
2215
|
+
if (origin.length > INT_255) {
|
|
2216
|
+
return false;
|
|
2217
|
+
}
|
|
2218
|
+
if (CONTROL_CHAR_PATTERN.test(origin)) {
|
|
2219
|
+
return false;
|
|
2220
|
+
}
|
|
2221
|
+
return /^https?:\/\//.test(origin);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/**
|
|
2225
|
+
* Sets Access-Control-Allow-Headers or Access-Control-Expose-Headers based on method
|
|
2226
|
+
* @param {Object} headersBatch - Headers batch object
|
|
2227
|
+
* @param {Object} req - HTTP request object
|
|
2228
|
+
* @param {*} corsHeaders - CORS headers value
|
|
2229
|
+
*/
|
|
2230
|
+
#setCorsAllowAndExposeHeaders(headersBatch, req, corsHeaders) {
|
|
2231
|
+
if (corsHeaders !== void 0) {
|
|
2232
|
+
headersBatch[
|
|
2233
|
+
req.method === OPTIONS ? ACCESS_CONTROL_ALLOW_HEADERS : ACCESS_CONTROL_EXPOSE_HEADERS
|
|
2234
|
+
] = corsHeaders;
|
|
2169
2235
|
}
|
|
2170
2236
|
}
|
|
2171
2237
|
|
|
@@ -2175,7 +2241,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2175
2241
|
* @returns {Woodland} Returns self for chaining
|
|
2176
2242
|
*/
|
|
2177
2243
|
delete(...args) {
|
|
2178
|
-
return this
|
|
2244
|
+
return this.#registerMethod(DELETE, ...args);
|
|
2179
2245
|
}
|
|
2180
2246
|
|
|
2181
2247
|
/**
|
|
@@ -2185,40 +2251,15 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2185
2251
|
* @returns {string} ETag string or empty string
|
|
2186
2252
|
*/
|
|
2187
2253
|
etag(method, ...args) {
|
|
2188
|
-
if (
|
|
2254
|
+
if ((method !== GET && method !== HEAD && method !== OPTIONS) || !this.#etags) {
|
|
2189
2255
|
return EMPTY;
|
|
2190
2256
|
}
|
|
2191
2257
|
|
|
2192
|
-
const hashed =
|
|
2193
|
-
return this.#etags.create(hashed);
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
/**
|
|
2197
|
-
* Checks if a method can be hashed for ETag generation
|
|
2198
|
-
* @param {string} method - HTTP method
|
|
2199
|
-
* @returns {boolean} True if method is GET, HEAD, or OPTIONS
|
|
2200
|
-
*/
|
|
2201
|
-
#isHashableMethod(method) {
|
|
2202
|
-
return method === GET || method === HEAD || method === OPTIONS;
|
|
2203
|
-
}
|
|
2204
|
-
|
|
2205
|
-
/**
|
|
2206
|
-
* Checks if ETags are enabled
|
|
2207
|
-
* @returns {boolean} True if ETags are enabled
|
|
2208
|
-
*/
|
|
2209
|
-
#etagsEnabled() {
|
|
2210
|
-
return this.#etags !== null;
|
|
2211
|
-
}
|
|
2212
|
-
|
|
2213
|
-
/**
|
|
2214
|
-
* Hashes arguments for ETag generation
|
|
2215
|
-
* @param {Array} args - Arguments to hash
|
|
2216
|
-
* @returns {string} Hashed string
|
|
2217
|
-
*/
|
|
2218
|
-
#hashArgs(args) {
|
|
2219
|
-
return args
|
|
2258
|
+
const hashed = args
|
|
2220
2259
|
.map((i) => (typeof i !== STRING ? JSON.stringify(i).replace(/^"|"$/g, EMPTY) : i))
|
|
2221
2260
|
.join(HYPHEN);
|
|
2261
|
+
|
|
2262
|
+
return this.#etags.create(hashed);
|
|
2222
2263
|
}
|
|
2223
2264
|
|
|
2224
2265
|
/**
|
|
@@ -2239,7 +2280,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2239
2280
|
* @returns {Woodland} Returns self for chaining
|
|
2240
2281
|
*/
|
|
2241
2282
|
get(...args) {
|
|
2242
|
-
return this
|
|
2283
|
+
return this.#registerMethod(GET, ...args);
|
|
2243
2284
|
}
|
|
2244
2285
|
|
|
2245
2286
|
/**
|
|
@@ -2299,8 +2340,8 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2299
2340
|
/* node:coverage ignore next 5 */
|
|
2300
2341
|
if (this.#time && res.getHeader(X_RESPONSE_TIME) === void 0) {
|
|
2301
2342
|
const diff = req.precise.stop().diff();
|
|
2302
|
-
const msValue = Number(diff /
|
|
2303
|
-
res.header(X_RESPONSE_TIME, `${msValue}
|
|
2343
|
+
const msValue = Number(diff / INT_1e6).toFixed(this.#digit);
|
|
2344
|
+
res.header(X_RESPONSE_TIME, `${msValue}${RESPONSE_TIME_UNIT}`);
|
|
2304
2345
|
}
|
|
2305
2346
|
|
|
2306
2347
|
return this.#onSend(req, res, body, status, headers);
|
|
@@ -2332,7 +2373,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2332
2373
|
* @returns {Woodland} Returns self for chaining
|
|
2333
2374
|
*/
|
|
2334
2375
|
options(...args) {
|
|
2335
|
-
return this
|
|
2376
|
+
return this.#registerMethod(OPTIONS, ...args);
|
|
2336
2377
|
}
|
|
2337
2378
|
|
|
2338
2379
|
/**
|
|
@@ -2341,7 +2382,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2341
2382
|
* @returns {Woodland} Returns self for chaining
|
|
2342
2383
|
*/
|
|
2343
2384
|
patch(...args) {
|
|
2344
|
-
return this
|
|
2385
|
+
return this.#registerMethod(PATCH, ...args);
|
|
2345
2386
|
}
|
|
2346
2387
|
|
|
2347
2388
|
/**
|
|
@@ -2350,7 +2391,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2350
2391
|
* @returns {Woodland} Returns self for chaining
|
|
2351
2392
|
*/
|
|
2352
2393
|
post(...args) {
|
|
2353
|
-
return this
|
|
2394
|
+
return this.#registerMethod(POST, ...args);
|
|
2354
2395
|
}
|
|
2355
2396
|
|
|
2356
2397
|
/**
|
|
@@ -2359,7 +2400,7 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2359
2400
|
* @returns {Woodland} Returns self for chaining
|
|
2360
2401
|
*/
|
|
2361
2402
|
put(...args) {
|
|
2362
|
-
return this
|
|
2403
|
+
return this.#registerMethod(PUT, ...args);
|
|
2363
2404
|
}
|
|
2364
2405
|
|
|
2365
2406
|
/**
|
|
@@ -2473,8 +2514,15 @@ class Woodland extends node_events.EventEmitter {
|
|
|
2473
2514
|
* Registers TRACE middleware
|
|
2474
2515
|
* @param {...*} args - Middleware function(s)
|
|
2475
2516
|
* @returns {Woodland} Returns self for chaining
|
|
2517
|
+
* @security TRACE method is vulnerable to XST attacks. Disabled by default.
|
|
2476
2518
|
*/
|
|
2477
2519
|
trace(...args) {
|
|
2520
|
+
if (this.#disableTrace) {
|
|
2521
|
+
this.#logger.log(
|
|
2522
|
+
`type=trace, message="TRACE method is disabled by default (XST vulnerability prevention)"`,
|
|
2523
|
+
);
|
|
2524
|
+
return this;
|
|
2525
|
+
}
|
|
2478
2526
|
return this.use(...args, TRACE);
|
|
2479
2527
|
}
|
|
2480
2528
|
|
package/dist/woodland.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @copyright 2026 Jason Mulligan <jason.mulligan@avoidwork.com>
|
|
5
5
|
* @license BSD-3-Clause
|
|
6
|
-
* @version 22.0
|
|
6
|
+
* @version 22.1.0
|
|
7
7
|
*/
|
|
8
8
|
import {STATUS_CODES}from'node:http';import {EventEmitter}from'node:events';import {readFileSync,createReadStream}from'node:fs';import {etag}from'tiny-etag';import {lru}from'tiny-lru';import {precise}from'precise';import {createRequire}from'node:module';import {join,extname,resolve,sep}from'node:path';import {fileURLToPath,URL as URL$1}from'node:url';import mimeDb from'mime-db';import {coerce}from'tiny-coerce';import {Validator}from'jsonschema';import {realpath,stat,readdir}from'node:fs/promises';const __dirname$2 = fileURLToPath(new URL$1(".", import.meta.url));
|
|
9
9
|
const require$1 = createRequire(import.meta.url);
|
|
@@ -36,6 +36,7 @@ const INT_400 = 400;
|
|
|
36
36
|
const INT_403 = 403;
|
|
37
37
|
const INT_404 = 404;
|
|
38
38
|
const INT_405 = 405;
|
|
39
|
+
const INT_413 = 413;
|
|
39
40
|
const INT_416 = 416;
|
|
40
41
|
const INT_500 = 500;
|
|
41
42
|
|
|
@@ -92,6 +93,7 @@ const INT_60 = 60;
|
|
|
92
93
|
const INT_255 = 255;
|
|
93
94
|
const INT_1e3 = 1e3;
|
|
94
95
|
const INT_1e4 = 1e4;
|
|
96
|
+
const INT_1e6$1 = 1e6;
|
|
95
97
|
const INT_8000 = 8000;
|
|
96
98
|
const INT_NEG_1 = -1;
|
|
97
99
|
|
|
@@ -210,6 +212,7 @@ const BYTES_SPACE = "bytes ";
|
|
|
210
212
|
// EVENT & STREAM CONSTANTS
|
|
211
213
|
// =============================================================================
|
|
212
214
|
const EVT_CLOSE = "close";
|
|
215
|
+
const EVT_DATA = "data";
|
|
213
216
|
const EVT_FINISH = "finish";
|
|
214
217
|
const EVT_STREAM = "stream";
|
|
215
218
|
const EVT_CONNECT = "connect";
|
|
@@ -229,6 +232,7 @@ const HTTP_VERSION = "HTTP/1.1";
|
|
|
229
232
|
const ITEM = "item";
|
|
230
233
|
const NOTICE = "notice";
|
|
231
234
|
const SHORT = "short";
|
|
235
|
+
const RESPONSE_TIME_UNIT = " ms";
|
|
232
236
|
const TO_STRING = "toString";
|
|
233
237
|
const TRUE = "true";
|
|
234
238
|
const WARN = "warn";
|
|
@@ -705,17 +709,20 @@ function escapeHtml(str = EMPTY) {
|
|
|
705
709
|
* @param {Object} req - Request object
|
|
706
710
|
* @param {Object} res - Response object
|
|
707
711
|
* @param {EventEmitter} emitter - EventEmitter for error events
|
|
708
|
-
* @
|
|
712
|
+
* @param {boolean} [exposeErrorMessages=false] - Expose internal error messages to clients
|
|
709
713
|
*/
|
|
710
|
-
function createErrorHandler(req, res, emitter) {
|
|
711
|
-
return (
|
|
712
|
-
error(req, res,
|
|
714
|
+
function createErrorHandler(req, res, emitter, exposeErrorMessages = false) {
|
|
715
|
+
return (inputStatus = res.statusCode, body) => {
|
|
716
|
+
error(req, res, inputStatus);
|
|
713
717
|
const err = body instanceof Error ? body : new Error(body ?? getStatusText(res.statusCode));
|
|
714
718
|
emitter.emit(EVT_ERROR, req, res, err);
|
|
715
719
|
if (req.headers) {
|
|
716
720
|
delete req.headers.range;
|
|
717
721
|
}
|
|
718
|
-
|
|
722
|
+
const message = exposeErrorMessages
|
|
723
|
+
? err.message || getStatusText(res.statusCode)
|
|
724
|
+
: getStatusText(res.statusCode);
|
|
725
|
+
res.send(message);
|
|
719
726
|
};
|
|
720
727
|
}
|
|
721
728
|
|
|
@@ -1325,13 +1332,16 @@ function registerMiddleware(middleware, ignored, methods, rpath, ...fn) {
|
|
|
1325
1332
|
});
|
|
1326
1333
|
}const DEFAULTS = {
|
|
1327
1334
|
autoIndex: false,
|
|
1335
|
+
bodyLimit: INT_10 * INT_1e6$1,
|
|
1328
1336
|
cacheSize: INT_1e3,
|
|
1329
1337
|
cacheTTL: INT_1e4,
|
|
1330
1338
|
charset: UTF_8,
|
|
1331
1339
|
corsExpose: EMPTY,
|
|
1332
1340
|
defaultHeaders: {},
|
|
1333
1341
|
digit: INT_3,
|
|
1342
|
+
disableTrace: true,
|
|
1334
1343
|
etags: true,
|
|
1344
|
+
exposeErrorMessages: false,
|
|
1335
1345
|
indexes: [INDEX_HTM, INDEX_HTML],
|
|
1336
1346
|
logging: {},
|
|
1337
1347
|
origins: [],
|
|
@@ -1344,13 +1354,16 @@ const CONFIG_SCHEMA = {
|
|
|
1344
1354
|
type: OBJECT,
|
|
1345
1355
|
properties: {
|
|
1346
1356
|
autoIndex: { type: BOOLEAN },
|
|
1357
|
+
bodyLimit: { type: NUMBER, minimum: INT_1 },
|
|
1347
1358
|
cacheSize: { type: NUMBER, minimum: INT_1 },
|
|
1348
1359
|
cacheTTL: { type: NUMBER, minimum: INT_1 },
|
|
1349
1360
|
charset: { type: STRING },
|
|
1350
1361
|
corsExpose: { type: STRING },
|
|
1351
1362
|
defaultHeaders: { type: OBJECT },
|
|
1352
1363
|
digit: { type: NUMBER, minimum: INT_1, maximum: INT_10 },
|
|
1364
|
+
disableTrace: { type: BOOLEAN },
|
|
1353
1365
|
etags: { type: BOOLEAN },
|
|
1366
|
+
exposeErrorMessages: { type: BOOLEAN },
|
|
1354
1367
|
indexes: { type: ARRAY, items: { type: STRING } },
|
|
1355
1368
|
logging: { type: OBJECT },
|
|
1356
1369
|
origins: { type: ARRAY, items: { type: STRING } },
|
|
@@ -1820,12 +1833,15 @@ class Woodland extends EventEmitter {
|
|
|
1820
1833
|
#charset;
|
|
1821
1834
|
#corsExpose;
|
|
1822
1835
|
#defaultHeaders;
|
|
1836
|
+
#disableTrace;
|
|
1823
1837
|
#digit;
|
|
1824
1838
|
#etags;
|
|
1839
|
+
#exposeErrorMessages;
|
|
1825
1840
|
#indexes;
|
|
1826
1841
|
#logging;
|
|
1827
1842
|
#origins;
|
|
1828
1843
|
#time;
|
|
1844
|
+
#bodyLimit;
|
|
1829
1845
|
#cache;
|
|
1830
1846
|
#methods;
|
|
1831
1847
|
#logger;
|
|
@@ -1835,14 +1851,17 @@ class Woodland extends EventEmitter {
|
|
|
1835
1851
|
/**
|
|
1836
1852
|
* Creates a new Woodland instance
|
|
1837
1853
|
* @param {Object} [config={}] - Configuration object
|
|
1854
|
+
* @param {number} [config.bodyLimit=10000000] - Max request body size in bytes
|
|
1838
1855
|
* @param {boolean} [config.autoIndex=false] - Enable automatic directory indexing
|
|
1839
1856
|
* @param {number} [config.cacheSize=1000] - Size of internal cache
|
|
1840
1857
|
* @param {number} [config.cacheTTL=10000] - Cache TTL in milliseconds
|
|
1841
1858
|
* @param {string} [config.charset='utf-8'] - Default charset
|
|
1842
1859
|
* @param {string} [config.corsExpose=''] - CORS expose headers
|
|
1860
|
+
* @param {boolean} [config.disableTrace=true] - Disable TRACE method (XST vulnerability prevention)
|
|
1843
1861
|
* @param {Object} [config.defaultHeaders={}] - Default headers to set
|
|
1844
1862
|
* @param {number} [config.digit=3] - Digit precision for timing
|
|
1845
1863
|
* @param {boolean} [config.etags=true] - Enable ETags
|
|
1864
|
+
* @param {boolean} [config.exposeErrorMessages=false] - Expose internal error messages to clients
|
|
1846
1865
|
* @param {Array} [config.indexes=['index.htm','index.html']] - Index files
|
|
1847
1866
|
* @param {Object} [config.logging={}] - Logging configuration
|
|
1848
1867
|
* @param {Array} [config.origins=[]] - Allowed CORS origins
|
|
@@ -1855,13 +1874,16 @@ class Woodland extends EventEmitter {
|
|
|
1855
1874
|
const validated = validateConfig(config);
|
|
1856
1875
|
|
|
1857
1876
|
this.#autoIndex = validated.autoIndex;
|
|
1877
|
+
this.#bodyLimit = validated.bodyLimit;
|
|
1858
1878
|
this.#charset = validated.charset;
|
|
1859
1879
|
this.#corsExpose = validated.corsExpose;
|
|
1860
1880
|
this.#defaultHeaders = this.#buildFinalHeaders(validated.defaultHeaders, validated.silent);
|
|
1881
|
+
this.#disableTrace = validated.disableTrace;
|
|
1861
1882
|
this.#digit = validated.digit;
|
|
1862
1883
|
this.#etags = validated.etags
|
|
1863
1884
|
? Object.freeze(etag({ cacheSize: validated.cacheSize, cacheTTL: validated.cacheTTL }))
|
|
1864
1885
|
: null;
|
|
1886
|
+
this.#exposeErrorMessages = validated.exposeErrorMessages;
|
|
1865
1887
|
this.#indexes = [...validated.indexes];
|
|
1866
1888
|
this.#logging = Object.freeze(validateLogging(validated.logging));
|
|
1867
1889
|
this.#origins = new Set(validated.origins);
|
|
@@ -1874,6 +1896,7 @@ class Woodland extends EventEmitter {
|
|
|
1874
1896
|
|
|
1875
1897
|
this.#setupMiddleware();
|
|
1876
1898
|
this.#setupErrorHandling();
|
|
1899
|
+
this.#setupBodyLimit();
|
|
1877
1900
|
}
|
|
1878
1901
|
|
|
1879
1902
|
/**
|
|
@@ -1946,15 +1969,38 @@ class Woodland extends EventEmitter {
|
|
|
1946
1969
|
);
|
|
1947
1970
|
}
|
|
1948
1971
|
|
|
1972
|
+
/**
|
|
1973
|
+
* Sets up body size limit middleware
|
|
1974
|
+
*/
|
|
1975
|
+
#setupBodyLimit() {
|
|
1976
|
+
const maxLimit = this.#bodyLimit;
|
|
1977
|
+
const handler = (req, res, next) => {
|
|
1978
|
+
let size = INT_0;
|
|
1979
|
+
/* node:coverage ignore next 1 */
|
|
1980
|
+
if (typeof req.on !== FUNCTION) {
|
|
1981
|
+
return next();
|
|
1982
|
+
}
|
|
1983
|
+
req.on(EVT_DATA, (chunk) => {
|
|
1984
|
+
size += chunk.length;
|
|
1985
|
+
/* node:coverage ignore next 3 */
|
|
1986
|
+
if (size > maxLimit) {
|
|
1987
|
+
req.destroy();
|
|
1988
|
+
res.error(INT_413);
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
next();
|
|
1992
|
+
};
|
|
1993
|
+
this.always(handler);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1949
1996
|
/**
|
|
1950
1997
|
* Determines allowed methods for a URI
|
|
1951
1998
|
* @param {string} uri - URI to check
|
|
1952
1999
|
* @param {boolean} [override=false] - Override cache
|
|
1953
|
-
* @param {boolean} [isCorsRequest=false] - Whether this is a CORS request
|
|
1954
2000
|
* @returns {string} Comma-separated list of allowed methods
|
|
1955
2001
|
*/
|
|
1956
|
-
#allows(uri, override = false
|
|
1957
|
-
const key =
|
|
2002
|
+
#allows(uri, override = false) {
|
|
2003
|
+
const key = `${DELIMITER}${uri}`;
|
|
1958
2004
|
let result = !override ? this.#cache.get(key) : void 0;
|
|
1959
2005
|
|
|
1960
2006
|
if (override || result === void 0) {
|
|
@@ -1966,7 +2012,7 @@ class Woodland extends EventEmitter {
|
|
|
1966
2012
|
}
|
|
1967
2013
|
}
|
|
1968
2014
|
|
|
1969
|
-
const list = this.#buildAllowedList(methodSet
|
|
2015
|
+
const list = this.#buildAllowedList(methodSet);
|
|
1970
2016
|
result = list.sort().join(COMMA_SPACE);
|
|
1971
2017
|
this.#cache.set(key, result);
|
|
1972
2018
|
this.#logger.log(
|
|
@@ -1978,12 +2024,11 @@ class Woodland extends EventEmitter {
|
|
|
1978
2024
|
}
|
|
1979
2025
|
|
|
1980
2026
|
/**
|
|
1981
|
-
* Builds the list of allowed methods including implicit HEAD
|
|
2027
|
+
* Builds the list of allowed methods including implicit HEAD, OPTIONS
|
|
1982
2028
|
* @param {Set} methodSet - Set of explicitly registered methods
|
|
1983
|
-
* @param {boolean} isCorsRequest - Whether this is a CORS request
|
|
1984
2029
|
* @returns {Array} Array of allowed methods
|
|
1985
2030
|
*/
|
|
1986
|
-
#buildAllowedList(methodSet
|
|
2031
|
+
#buildAllowedList(methodSet) {
|
|
1987
2032
|
const list = [...methodSet];
|
|
1988
2033
|
|
|
1989
2034
|
if (list.length > INT_0) {
|
|
@@ -1991,8 +2036,7 @@ class Woodland extends EventEmitter {
|
|
|
1991
2036
|
list.push(HEAD);
|
|
1992
2037
|
}
|
|
1993
2038
|
|
|
1994
|
-
|
|
1995
|
-
if (!methodSet.has(OPTIONS) && isCorsRequest) {
|
|
2039
|
+
if (!methodSet.has(OPTIONS)) {
|
|
1996
2040
|
list.push(OPTIONS);
|
|
1997
2041
|
}
|
|
1998
2042
|
}
|
|
@@ -2015,13 +2059,23 @@ class Woodland extends EventEmitter {
|
|
|
2015
2059
|
return this.use(...args, WILDCARD);
|
|
2016
2060
|
}
|
|
2017
2061
|
|
|
2062
|
+
/**
|
|
2063
|
+
* Registers middleware for a given HTTP method
|
|
2064
|
+
* @param {string} method - HTTP method name
|
|
2065
|
+
* @param {...*} args - Middleware function(s)
|
|
2066
|
+
* @returns {Woodland} Returns self for chaining
|
|
2067
|
+
*/
|
|
2068
|
+
#registerMethod(method, ...args) {
|
|
2069
|
+
return this.use(...args, method);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2018
2072
|
/**
|
|
2019
2073
|
* Registers CONNECT middleware
|
|
2020
2074
|
* @param {...*} args - Middleware function(s)
|
|
2021
2075
|
* @returns {Woodland} Returns self for chaining
|
|
2022
2076
|
*/
|
|
2023
2077
|
connect(...args) {
|
|
2024
|
-
return this
|
|
2078
|
+
return this.#registerMethod(CONNECT, ...args);
|
|
2025
2079
|
}
|
|
2026
2080
|
|
|
2027
2081
|
/**
|
|
@@ -2043,8 +2097,20 @@ class Woodland extends EventEmitter {
|
|
|
2043
2097
|
req.params = {};
|
|
2044
2098
|
req.valid = true;
|
|
2045
2099
|
|
|
2046
|
-
const allowString = this.#allows(parsed.pathname
|
|
2047
|
-
const headersBatch =
|
|
2100
|
+
const allowString = this.#allows(parsed.pathname);
|
|
2101
|
+
const headersBatch = Object.create(null);
|
|
2102
|
+
headersBatch[ALLOW] = allowString;
|
|
2103
|
+
headersBatch[X_CONTENT_TYPE_OPTIONS] = NO_SNIFF;
|
|
2104
|
+
|
|
2105
|
+
const defaultHeaders = this.#defaultHeaders;
|
|
2106
|
+
const headerCount = defaultHeaders.length;
|
|
2107
|
+
for (let i = INT_0; i < headerCount; i++) {
|
|
2108
|
+
const [key, value] = defaultHeaders[i];
|
|
2109
|
+
if (typeof key === STRING && (typeof value === STRING || typeof value === NUMBER)) {
|
|
2110
|
+
headersBatch[key] = value;
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2048
2114
|
req.allow = allowString;
|
|
2049
2115
|
|
|
2050
2116
|
if (timing) {
|
|
@@ -2061,28 +2127,6 @@ class Woodland extends EventEmitter {
|
|
|
2061
2127
|
);
|
|
2062
2128
|
}
|
|
2063
2129
|
|
|
2064
|
-
/**
|
|
2065
|
-
* Builds default headers batch
|
|
2066
|
-
* @param {string} allowString - Allow header value
|
|
2067
|
-
* @returns {Object} Headers batch object
|
|
2068
|
-
*/
|
|
2069
|
-
#buildDefaultHeaders(allowString) {
|
|
2070
|
-
const headersBatch = Object.create(null);
|
|
2071
|
-
headersBatch[ALLOW] = allowString;
|
|
2072
|
-
headersBatch[X_CONTENT_TYPE_OPTIONS] = NO_SNIFF;
|
|
2073
|
-
|
|
2074
|
-
const defaultHeaders = this.#defaultHeaders;
|
|
2075
|
-
const headerCount = defaultHeaders.length;
|
|
2076
|
-
for (let i = INT_0; i < headerCount; i++) {
|
|
2077
|
-
const [key, value] = defaultHeaders[i];
|
|
2078
|
-
if (typeof key === STRING && (typeof value === STRING || typeof value === NUMBER)) {
|
|
2079
|
-
headersBatch[key] = value;
|
|
2080
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
|
|
2083
|
-
return headersBatch;
|
|
2084
|
-
}
|
|
2085
|
-
|
|
2086
2130
|
/**
|
|
2087
2131
|
* Decorates response object with methods and event handlers
|
|
2088
2132
|
* @param {Object} res - HTTP response object
|
|
@@ -2091,7 +2135,7 @@ class Woodland extends EventEmitter {
|
|
|
2091
2135
|
*/
|
|
2092
2136
|
#decorateResponse(res, req, headersBatch) {
|
|
2093
2137
|
res.locals = {};
|
|
2094
|
-
res.error = createErrorHandler(req, res, this);
|
|
2138
|
+
res.error = createErrorHandler(req, res, this, this.#exposeErrorMessages);
|
|
2095
2139
|
res.header = res.setHeader;
|
|
2096
2140
|
res.json = createJsonHandler(res);
|
|
2097
2141
|
res.redirect = createRedirectHandler(res);
|
|
@@ -2110,8 +2154,7 @@ class Woodland extends EventEmitter {
|
|
|
2110
2154
|
*/
|
|
2111
2155
|
#addCorsHeaders(req, headersBatch) {
|
|
2112
2156
|
const origin = req.headers.origin;
|
|
2113
|
-
const
|
|
2114
|
-
const originAllowed = this.#origins.has(origin);
|
|
2157
|
+
const originAllowed = this.#isSafeOrigin(origin) && this.#origins.has(origin);
|
|
2115
2158
|
const hasWildcard = this.#origins.has(WILDCARD);
|
|
2116
2159
|
|
|
2117
2160
|
/* node:coverage ignore next 9 */
|
|
@@ -2120,21 +2163,44 @@ class Woodland extends EventEmitter {
|
|
|
2120
2163
|
headersBatch[TIMING_ALLOW_ORIGIN] = origin;
|
|
2121
2164
|
headersBatch[ACCESS_CONTROL_ALLOW_CREDENTIALS] = TRUE;
|
|
2122
2165
|
headersBatch[ACCESS_CONTROL_ALLOW_METHODS] = req.allow;
|
|
2123
|
-
|
|
2124
|
-
if (corsHeaders !== void 0) {
|
|
2125
|
-
headersBatch[
|
|
2126
|
-
req.method === OPTIONS ? ACCESS_CONTROL_ALLOW_HEADERS : ACCESS_CONTROL_EXPOSE_HEADERS
|
|
2127
|
-
] = corsHeaders;
|
|
2128
|
-
}
|
|
2129
2166
|
} else if (hasWildcard) {
|
|
2130
2167
|
headersBatch[ACCESS_CONTROL_ALLOW_ORIGIN] = WILDCARD;
|
|
2131
2168
|
headersBatch[ACCESS_CONTROL_ALLOW_METHODS] = req.allow;
|
|
2169
|
+
}
|
|
2132
2170
|
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2171
|
+
const corsHeaders = req.headers[ACCESS_CONTROL_REQUEST_HEADERS] ?? this.#corsExpose;
|
|
2172
|
+
this.#setCorsAllowAndExposeHeaders(headersBatch, req, corsHeaders);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
/**
|
|
2176
|
+
* Validates origin header for safety
|
|
2177
|
+
* @param {string} origin - Origin header value
|
|
2178
|
+
* @returns {boolean} True if origin is safe
|
|
2179
|
+
*/
|
|
2180
|
+
#isSafeOrigin(origin) {
|
|
2181
|
+
if (!origin || typeof origin !== STRING) {
|
|
2182
|
+
return false;
|
|
2183
|
+
}
|
|
2184
|
+
if (origin.length > INT_255) {
|
|
2185
|
+
return false;
|
|
2186
|
+
}
|
|
2187
|
+
if (CONTROL_CHAR_PATTERN.test(origin)) {
|
|
2188
|
+
return false;
|
|
2189
|
+
}
|
|
2190
|
+
return /^https?:\/\//.test(origin);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
/**
|
|
2194
|
+
* Sets Access-Control-Allow-Headers or Access-Control-Expose-Headers based on method
|
|
2195
|
+
* @param {Object} headersBatch - Headers batch object
|
|
2196
|
+
* @param {Object} req - HTTP request object
|
|
2197
|
+
* @param {*} corsHeaders - CORS headers value
|
|
2198
|
+
*/
|
|
2199
|
+
#setCorsAllowAndExposeHeaders(headersBatch, req, corsHeaders) {
|
|
2200
|
+
if (corsHeaders !== void 0) {
|
|
2201
|
+
headersBatch[
|
|
2202
|
+
req.method === OPTIONS ? ACCESS_CONTROL_ALLOW_HEADERS : ACCESS_CONTROL_EXPOSE_HEADERS
|
|
2203
|
+
] = corsHeaders;
|
|
2138
2204
|
}
|
|
2139
2205
|
}
|
|
2140
2206
|
|
|
@@ -2144,7 +2210,7 @@ class Woodland extends EventEmitter {
|
|
|
2144
2210
|
* @returns {Woodland} Returns self for chaining
|
|
2145
2211
|
*/
|
|
2146
2212
|
delete(...args) {
|
|
2147
|
-
return this
|
|
2213
|
+
return this.#registerMethod(DELETE, ...args);
|
|
2148
2214
|
}
|
|
2149
2215
|
|
|
2150
2216
|
/**
|
|
@@ -2154,40 +2220,15 @@ class Woodland extends EventEmitter {
|
|
|
2154
2220
|
* @returns {string} ETag string or empty string
|
|
2155
2221
|
*/
|
|
2156
2222
|
etag(method, ...args) {
|
|
2157
|
-
if (
|
|
2223
|
+
if ((method !== GET && method !== HEAD && method !== OPTIONS) || !this.#etags) {
|
|
2158
2224
|
return EMPTY;
|
|
2159
2225
|
}
|
|
2160
2226
|
|
|
2161
|
-
const hashed =
|
|
2162
|
-
return this.#etags.create(hashed);
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
/**
|
|
2166
|
-
* Checks if a method can be hashed for ETag generation
|
|
2167
|
-
* @param {string} method - HTTP method
|
|
2168
|
-
* @returns {boolean} True if method is GET, HEAD, or OPTIONS
|
|
2169
|
-
*/
|
|
2170
|
-
#isHashableMethod(method) {
|
|
2171
|
-
return method === GET || method === HEAD || method === OPTIONS;
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
/**
|
|
2175
|
-
* Checks if ETags are enabled
|
|
2176
|
-
* @returns {boolean} True if ETags are enabled
|
|
2177
|
-
*/
|
|
2178
|
-
#etagsEnabled() {
|
|
2179
|
-
return this.#etags !== null;
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
/**
|
|
2183
|
-
* Hashes arguments for ETag generation
|
|
2184
|
-
* @param {Array} args - Arguments to hash
|
|
2185
|
-
* @returns {string} Hashed string
|
|
2186
|
-
*/
|
|
2187
|
-
#hashArgs(args) {
|
|
2188
|
-
return args
|
|
2227
|
+
const hashed = args
|
|
2189
2228
|
.map((i) => (typeof i !== STRING ? JSON.stringify(i).replace(/^"|"$/g, EMPTY) : i))
|
|
2190
2229
|
.join(HYPHEN);
|
|
2230
|
+
|
|
2231
|
+
return this.#etags.create(hashed);
|
|
2191
2232
|
}
|
|
2192
2233
|
|
|
2193
2234
|
/**
|
|
@@ -2208,7 +2249,7 @@ class Woodland extends EventEmitter {
|
|
|
2208
2249
|
* @returns {Woodland} Returns self for chaining
|
|
2209
2250
|
*/
|
|
2210
2251
|
get(...args) {
|
|
2211
|
-
return this
|
|
2252
|
+
return this.#registerMethod(GET, ...args);
|
|
2212
2253
|
}
|
|
2213
2254
|
|
|
2214
2255
|
/**
|
|
@@ -2268,8 +2309,8 @@ class Woodland extends EventEmitter {
|
|
|
2268
2309
|
/* node:coverage ignore next 5 */
|
|
2269
2310
|
if (this.#time && res.getHeader(X_RESPONSE_TIME) === void 0) {
|
|
2270
2311
|
const diff = req.precise.stop().diff();
|
|
2271
|
-
const msValue = Number(diff /
|
|
2272
|
-
res.header(X_RESPONSE_TIME, `${msValue}
|
|
2312
|
+
const msValue = Number(diff / INT_1e6).toFixed(this.#digit);
|
|
2313
|
+
res.header(X_RESPONSE_TIME, `${msValue}${RESPONSE_TIME_UNIT}`);
|
|
2273
2314
|
}
|
|
2274
2315
|
|
|
2275
2316
|
return this.#onSend(req, res, body, status, headers);
|
|
@@ -2301,7 +2342,7 @@ class Woodland extends EventEmitter {
|
|
|
2301
2342
|
* @returns {Woodland} Returns self for chaining
|
|
2302
2343
|
*/
|
|
2303
2344
|
options(...args) {
|
|
2304
|
-
return this
|
|
2345
|
+
return this.#registerMethod(OPTIONS, ...args);
|
|
2305
2346
|
}
|
|
2306
2347
|
|
|
2307
2348
|
/**
|
|
@@ -2310,7 +2351,7 @@ class Woodland extends EventEmitter {
|
|
|
2310
2351
|
* @returns {Woodland} Returns self for chaining
|
|
2311
2352
|
*/
|
|
2312
2353
|
patch(...args) {
|
|
2313
|
-
return this
|
|
2354
|
+
return this.#registerMethod(PATCH, ...args);
|
|
2314
2355
|
}
|
|
2315
2356
|
|
|
2316
2357
|
/**
|
|
@@ -2319,7 +2360,7 @@ class Woodland extends EventEmitter {
|
|
|
2319
2360
|
* @returns {Woodland} Returns self for chaining
|
|
2320
2361
|
*/
|
|
2321
2362
|
post(...args) {
|
|
2322
|
-
return this
|
|
2363
|
+
return this.#registerMethod(POST, ...args);
|
|
2323
2364
|
}
|
|
2324
2365
|
|
|
2325
2366
|
/**
|
|
@@ -2328,7 +2369,7 @@ class Woodland extends EventEmitter {
|
|
|
2328
2369
|
* @returns {Woodland} Returns self for chaining
|
|
2329
2370
|
*/
|
|
2330
2371
|
put(...args) {
|
|
2331
|
-
return this
|
|
2372
|
+
return this.#registerMethod(PUT, ...args);
|
|
2332
2373
|
}
|
|
2333
2374
|
|
|
2334
2375
|
/**
|
|
@@ -2442,8 +2483,15 @@ class Woodland extends EventEmitter {
|
|
|
2442
2483
|
* Registers TRACE middleware
|
|
2443
2484
|
* @param {...*} args - Middleware function(s)
|
|
2444
2485
|
* @returns {Woodland} Returns self for chaining
|
|
2486
|
+
* @security TRACE method is vulnerable to XST attacks. Disabled by default.
|
|
2445
2487
|
*/
|
|
2446
2488
|
trace(...args) {
|
|
2489
|
+
if (this.#disableTrace) {
|
|
2490
|
+
this.#logger.log(
|
|
2491
|
+
`type=trace, message="TRACE method is disabled by default (XST vulnerability prevention)"`,
|
|
2492
|
+
);
|
|
2493
|
+
return this;
|
|
2494
|
+
}
|
|
2447
2495
|
return this.use(...args, TRACE);
|
|
2448
2496
|
}
|
|
2449
2497
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "woodland",
|
|
3
|
-
"version": "22.0
|
|
3
|
+
"version": "22.1.0",
|
|
4
4
|
"description": "Secure HTTP framework for Node.js. Express-compatible with built-in security, no performance tradeoff.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"auto-changelog": "^2.5.0",
|
|
75
75
|
"husky": "^9.1.7",
|
|
76
|
-
"oxfmt": "^0.
|
|
76
|
+
"oxfmt": "^0.46.0",
|
|
77
77
|
"oxlint": "^1.57.0",
|
|
78
78
|
"rimraf": "^6.1.3",
|
|
79
79
|
"rollup": "^4.60.0"
|
package/types/woodland.d.ts
CHANGED
|
@@ -2,13 +2,16 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
|
|
3
3
|
export interface WoodlandConfig {
|
|
4
4
|
autoIndex?: boolean;
|
|
5
|
+
bodyLimit?: number;
|
|
5
6
|
cacheSize?: number;
|
|
6
7
|
cacheTTL?: number;
|
|
7
8
|
charset?: string;
|
|
8
9
|
corsExpose?: string;
|
|
9
10
|
defaultHeaders?: Record<string, string>;
|
|
10
11
|
digit?: number;
|
|
12
|
+
disableTrace?: boolean;
|
|
11
13
|
etags?: boolean;
|
|
14
|
+
exposeErrorMessages?: boolean;
|
|
12
15
|
indexes?: string[];
|
|
13
16
|
logging?: object;
|
|
14
17
|
origins?: string[];
|