@centralping/ergo 0.1.0-beta.1
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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/http/accepts.js +69 -0
- package/http/authorization.js +65 -0
- package/http/body.js +311 -0
- package/http/cache-control.js +123 -0
- package/http/compress.js +157 -0
- package/http/cookie.js +39 -0
- package/http/cors.js +79 -0
- package/http/csrf.js +76 -0
- package/http/handler.js +74 -0
- package/http/index.js +13 -0
- package/http/json-api-query.js +53 -0
- package/http/logger.js +167 -0
- package/http/main.js +140 -0
- package/http/precondition.js +53 -0
- package/http/prefer.js +36 -0
- package/http/rate-limit.js +66 -0
- package/http/security-headers.js +62 -0
- package/http/send.js +399 -0
- package/http/timeout.js +47 -0
- package/http/url.js +47 -0
- package/http/validate.js +84 -0
- package/lib/accepts.js +49 -0
- package/lib/attach-instance.js +23 -0
- package/lib/authorization.js +187 -0
- package/lib/body/multiparse.js +173 -0
- package/lib/body/multipart/headers.js +69 -0
- package/lib/body/writer.js +73 -0
- package/lib/cookie/cookie.js +192 -0
- package/lib/cookie/index.js +14 -0
- package/lib/cookie/jar.js +106 -0
- package/lib/cookie/parse.js +101 -0
- package/lib/cors.js +191 -0
- package/lib/csrf.js +96 -0
- package/lib/from-connect.js +69 -0
- package/lib/json-api-query/index.js +25 -0
- package/lib/json-api-query/schema.json +105 -0
- package/lib/json-api-query/validate.js +56 -0
- package/lib/link.js +96 -0
- package/lib/prefer.js +52 -0
- package/lib/query.js +113 -0
- package/lib/rate-limit.js +115 -0
- package/lib/sanitize-quoted-string.js +28 -0
- package/lib/security-headers.js +125 -0
- package/lib/validate.js +80 -0
- package/lib/vary.js +40 -0
- package/package.json +158 -0
- package/types/http/accepts.d.ts +8 -0
- package/types/http/authorization.d.ts +8 -0
- package/types/http/body.d.ts +20 -0
- package/types/http/cache-control.d.ts +16 -0
- package/types/http/compress.d.ts +5 -0
- package/types/http/cookie.d.ts +2 -0
- package/types/http/cors.d.ts +9 -0
- package/types/http/csrf.d.ts +9 -0
- package/types/http/handler.d.ts +2 -0
- package/types/http/index.d.ts +1 -0
- package/types/http/json-api-query.d.ts +2 -0
- package/types/http/logger.d.ts +9 -0
- package/types/http/main.d.ts +142 -0
- package/types/http/precondition.d.ts +44 -0
- package/types/http/prefer.d.ts +2 -0
- package/types/http/rate-limit.d.ts +17 -0
- package/types/http/security-headers.d.ts +10 -0
- package/types/http/send.d.ts +8 -0
- package/types/http/timeout.d.ts +5 -0
- package/types/http/url.d.ts +2 -0
- package/types/http/validate.d.ts +6 -0
- package/types/lib/accepts.d.ts +7 -0
- package/types/lib/attach-instance.d.ts +19 -0
- package/types/lib/authorization.d.ts +6 -0
- package/types/lib/body/multiparse.d.ts +9 -0
- package/types/lib/body/multipart/headers.d.ts +2 -0
- package/types/lib/body/writer.d.ts +2 -0
- package/types/lib/cookie/cookie.d.ts +32 -0
- package/types/lib/cookie/index.d.ts +2 -0
- package/types/lib/cookie/jar.d.ts +8 -0
- package/types/lib/cookie/parse.d.ts +19 -0
- package/types/lib/cors.d.ts +9 -0
- package/types/lib/csrf.d.ts +32 -0
- package/types/lib/from-connect.d.ts +47 -0
- package/types/lib/json-api-query/index.d.ts +123 -0
- package/types/lib/json-api-query/validate.d.ts +5 -0
- package/types/lib/link.d.ts +37 -0
- package/types/lib/prefer.d.ts +36 -0
- package/types/lib/query.d.ts +6 -0
- package/types/lib/rate-limit.d.ts +76 -0
- package/types/lib/sanitize-quoted-string.d.ts +19 -0
- package/types/lib/security-headers.d.ts +24 -0
- package/types/lib/validate.d.ts +16 -0
- package/types/lib/vary.d.ts +17 -0
- package/types/utils/attempt.d.ts +2 -0
- package/types/utils/buffers/index.d.ts +2 -0
- package/types/utils/buffers/match.d.ts +10 -0
- package/types/utils/buffers/split.d.ts +10 -0
- package/types/utils/compose-with.d.ts +40 -0
- package/types/utils/compose.d.ts +83 -0
- package/types/utils/flat-array.d.ts +2 -0
- package/types/utils/get.d.ts +5 -0
- package/types/utils/http-errors.d.ts +22 -0
- package/types/utils/iterables/buffer-split.d.ts +2 -0
- package/types/utils/iterables/chain.d.ts +2 -0
- package/types/utils/iterables/exec-all.d.ts +2 -0
- package/types/utils/iterables/filter.d.ts +2 -0
- package/types/utils/iterables/for-each.d.ts +2 -0
- package/types/utils/iterables/from-stream.d.ts +2 -0
- package/types/utils/iterables/index.d.ts +10 -0
- package/types/utils/iterables/map.d.ts +2 -0
- package/types/utils/iterables/range.d.ts +24 -0
- package/types/utils/iterables/reduce.d.ts +2 -0
- package/types/utils/iterables/take.d.ts +2 -0
- package/types/utils/observables/buffer-split.d.ts +2 -0
- package/types/utils/observables/chain.d.ts +2 -0
- package/types/utils/observables/index.d.ts +4 -0
- package/types/utils/observables/map.d.ts +2 -0
- package/types/utils/observables/take.d.ts +2 -0
- package/types/utils/pick.d.ts +2 -0
- package/types/utils/set.d.ts +2 -0
- package/types/utils/streams/index.d.ts +2 -0
- package/types/utils/streams/meter.d.ts +5 -0
- package/types/utils/streams/tee.d.ts +2 -0
- package/types/utils/type.d.ts +2 -0
- package/utils/attempt.js +37 -0
- package/utils/buffers/index.js +13 -0
- package/utils/buffers/match.js +96 -0
- package/utils/buffers/split.js +55 -0
- package/utils/compose-with.js +232 -0
- package/utils/compose.js +165 -0
- package/utils/flat-array.js +24 -0
- package/utils/get.js +39 -0
- package/utils/http-errors.js +113 -0
- package/utils/iterables/buffer-split.js +117 -0
- package/utils/iterables/chain.js +32 -0
- package/utils/iterables/exec-all.js +42 -0
- package/utils/iterables/filter.js +35 -0
- package/utils/iterables/for-each.js +33 -0
- package/utils/iterables/from-stream.js +29 -0
- package/utils/iterables/index.js +21 -0
- package/utils/iterables/map.js +47 -0
- package/utils/iterables/range.js +34 -0
- package/utils/iterables/reduce.js +43 -0
- package/utils/iterables/take.js +36 -0
- package/utils/observables/buffer-split.js +109 -0
- package/utils/observables/chain.js +33 -0
- package/utils/observables/index.js +19 -0
- package/utils/observables/map.js +34 -0
- package/utils/observables/take.js +40 -0
- package/utils/pick.js +41 -0
- package/utils/set.js +38 -0
- package/utils/streams/index.js +11 -0
- package/utils/streams/meter.js +98 -0
- package/utils/streams/tee.js +84 -0
- package/utils/type.js +47 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Authorization header parsing and strategy dispatch logic.
|
|
3
|
+
*
|
|
4
|
+
* Parses the HTTP `Authorization` header and dispatches to the matching strategy handler.
|
|
5
|
+
* Supports three built-in schemes with pluggable `authorizer` callbacks:
|
|
6
|
+
* - **Basic** — decodes base64 `username:password` credentials, passes `(attributes, username, password)`
|
|
7
|
+
* - **Bearer** — passes the raw token string unchanged `(attributes, token)`; no decoding (JWTs and opaque tokens are not base64 encoded at the HTTP layer)
|
|
8
|
+
* - **$default** — passes raw credentials for any custom scheme `(attributes, credentials)`
|
|
9
|
+
*
|
|
10
|
+
* Each strategy returns `{authorized: boolean, info: object}`. Authorization failure returns
|
|
11
|
+
* 401 for known schemes (with a `WWW-Authenticate` challenge) or 403 for unrecognized schemes.
|
|
12
|
+
*
|
|
13
|
+
* @module lib/authorization
|
|
14
|
+
* @version 0.1.0
|
|
15
|
+
* @since 0.1.0
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import authorize from 'ergo/lib/authorization';
|
|
19
|
+
*
|
|
20
|
+
* const authorizer = authorize([{
|
|
21
|
+
* type: 'Bearer',
|
|
22
|
+
* attributes: {realm: 'API'},
|
|
23
|
+
* authorizer: async (attrs, token) => {
|
|
24
|
+
* const user = await verifyJwt(token);
|
|
25
|
+
* return user ? {authorized: true, info: {user}} : {authorized: false, info: {}};
|
|
26
|
+
* }
|
|
27
|
+
* }]);
|
|
28
|
+
*
|
|
29
|
+
* const result = await authorizer('Bearer eyJ...');
|
|
30
|
+
* // result => {authorized: true, info: {user: {...}}}
|
|
31
|
+
*
|
|
32
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc6750 RFC 6750 - Bearer Token Usage}
|
|
33
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc7617 RFC 7617 - The 'Basic' HTTP Authentication Scheme}
|
|
34
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc7235 RFC 7235 - HTTP Authentication}
|
|
35
|
+
*/
|
|
36
|
+
import sanitizeQuotedString from './sanitize-quoted-string.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates an authorization dispatcher for the given strategy list.
|
|
40
|
+
*
|
|
41
|
+
* @param {Array<{type: string, attributes?: object, authorizer: function}>} strategies - Authentication strategy definitions
|
|
42
|
+
* @returns {function} - Async `(authorization) => {authorized, info}`
|
|
43
|
+
*/
|
|
44
|
+
export default (strategies = []) => {
|
|
45
|
+
const dispatcher = createDispatcher(strategies);
|
|
46
|
+
|
|
47
|
+
return async (authorization = '') => {
|
|
48
|
+
const [type, rawCredentials] = authorization.split(/ (.*)$/);
|
|
49
|
+
const strategy = dispatcher[type.toLowerCase()];
|
|
50
|
+
|
|
51
|
+
if (strategy === undefined || rawCredentials === undefined) {
|
|
52
|
+
const obj = {
|
|
53
|
+
authorized: false,
|
|
54
|
+
info: {
|
|
55
|
+
statusCode: 403
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (Object.keys(dispatcher).length > 0) {
|
|
60
|
+
obj.info = {
|
|
61
|
+
statusCode: 401,
|
|
62
|
+
authenticate: Object.values(dispatcher).map(s => s.authenticate)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return obj;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return await strategy.authorizer(rawCredentials);
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const dispatchHelper = new Proxy(
|
|
74
|
+
{
|
|
75
|
+
basic({authorizer, attributes, authenticate}) {
|
|
76
|
+
return {
|
|
77
|
+
authenticate,
|
|
78
|
+
authorizer: async rawCredentials => {
|
|
79
|
+
const decoded = Buffer.from(rawCredentials, 'base64').toString();
|
|
80
|
+
const [username, password] = decoded.split(/:(.*)$/);
|
|
81
|
+
const {authorized = false, info = {}} = await authorizer(attributes, username, password);
|
|
82
|
+
|
|
83
|
+
if (authorized === false) {
|
|
84
|
+
return {
|
|
85
|
+
authorized,
|
|
86
|
+
info: {
|
|
87
|
+
statusCode: 403
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {authorized, info};
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
bearer({authorizer, attributes, authenticate}) {
|
|
97
|
+
return {
|
|
98
|
+
authenticate,
|
|
99
|
+
authorizer: async rawCredentials => {
|
|
100
|
+
const {authorized = false, info = {}} = await authorizer(attributes, rawCredentials);
|
|
101
|
+
|
|
102
|
+
if (authorized === false) {
|
|
103
|
+
return {
|
|
104
|
+
authorized,
|
|
105
|
+
info: {
|
|
106
|
+
statusCode:
|
|
107
|
+
info.type === 'invalid_request'
|
|
108
|
+
? 400
|
|
109
|
+
: info.type === 'insufficient_scope'
|
|
110
|
+
? 403
|
|
111
|
+
: 401, // default and 'invalid_token'
|
|
112
|
+
authenticate: [authenticate, ...formatError(info)].join(', ')
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {authorized, info};
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
$default({authorizer, attributes, authenticate}) {
|
|
122
|
+
return {
|
|
123
|
+
authenticate,
|
|
124
|
+
authorizer: async rawCredentials => {
|
|
125
|
+
const {authorized = false, info = {}} = await authorizer(attributes, rawCredentials);
|
|
126
|
+
|
|
127
|
+
if (authorized === false) {
|
|
128
|
+
return {
|
|
129
|
+
authorized,
|
|
130
|
+
info: {
|
|
131
|
+
statusCode: 403
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {authorized, info};
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
get(o, p) {
|
|
143
|
+
return Object.hasOwn(o, p) ? o[p] : o.$default;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Builds a dispatcher map from the strategy array, keyed by lowercase scheme name.
|
|
150
|
+
*
|
|
151
|
+
* @param {Array<{type: string, attributes?: object, authorizer: function}>} strategies - Authentication strategy definitions
|
|
152
|
+
* @returns {object} - Dispatcher map keyed by lowercase scheme name
|
|
153
|
+
*/
|
|
154
|
+
function createDispatcher(strategies) {
|
|
155
|
+
return strategies.reduce((o, {type, attributes = {realm: 'Users'}, authorizer}) => {
|
|
156
|
+
o[type.toLowerCase()] = dispatchHelper[type.toLowerCase()]({
|
|
157
|
+
attributes,
|
|
158
|
+
authorizer,
|
|
159
|
+
authenticate: `${type} ${Object.entries(attributes)
|
|
160
|
+
.map(([k, v]) => `${k}="${sanitizeQuotedString(v)}"`)
|
|
161
|
+
.join(', ')}`
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return o;
|
|
165
|
+
}, {});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const errorPropMap = [
|
|
169
|
+
['type', 'error'],
|
|
170
|
+
['desc', 'error_description'],
|
|
171
|
+
['uri', 'error_uri']
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Formats a Bearer token error object into WWW-Authenticate parameter strings (RFC 6750).
|
|
176
|
+
*
|
|
177
|
+
* @param {object} error - Error info from the authorizer
|
|
178
|
+
* @param {string} [error.type] - OAuth error type (e.g. 'invalid_token')
|
|
179
|
+
* @param {string} [error.desc] - Human-readable error description
|
|
180
|
+
* @param {string} [error.uri] - URI to more error information
|
|
181
|
+
* @returns {string[]} - Array of key="value" attribute strings for the WWW-Authenticate header
|
|
182
|
+
*/
|
|
183
|
+
function formatError(error) {
|
|
184
|
+
return errorPropMap
|
|
185
|
+
.filter(([p]) => error[p] !== undefined)
|
|
186
|
+
.map(([p, k]) => `${k}="${sanitizeQuotedString(error[p])}"`);
|
|
187
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview RFC 7578 multipart/form-data parser.
|
|
3
|
+
*
|
|
4
|
+
* Parses a fully buffered `multipart/form-data` body into an array of part descriptors.
|
|
5
|
+
* Each part contains parsed headers, the binary body buffer, and convenience shortcuts
|
|
6
|
+
* for the `Content-Disposition` `name` and `filename` parameters.
|
|
7
|
+
*
|
|
8
|
+
* Uses Buffer-level KMP split (`utils/iterables/buffer-split`) for efficient binary
|
|
9
|
+
* boundary detection without converting the entire body to a string.
|
|
10
|
+
*
|
|
11
|
+
* Only the MIME headers explicitly allowed by RFC 7578 §4.8 are parsed:
|
|
12
|
+
* `Content-Disposition`, `Content-Type`, `Content-Transfer-Encoding`.
|
|
13
|
+
*
|
|
14
|
+
* @module lib/body/multiparse
|
|
15
|
+
* @version 0.1.0
|
|
16
|
+
* @since 0.1.0
|
|
17
|
+
* @requires ./multipart/headers.js
|
|
18
|
+
* @requires ../../utils/iterables/buffer-split.js
|
|
19
|
+
* @requires ../../utils/iterables/chain.js
|
|
20
|
+
*
|
|
21
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc7578}
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* import multiparse from 'ergo/lib/body/multiparse';
|
|
25
|
+
*
|
|
26
|
+
* const parts = multiparse(rawBodyBuffer, 'boundary-string');
|
|
27
|
+
* // parts => [{headers: {...}, name: 'file', filename: 'upload.txt', body: Buffer}]
|
|
28
|
+
*/
|
|
29
|
+
// https://www.rfc-editor.org/rfc/rfc7578
|
|
30
|
+
import parseHeaders from './multipart/headers.js';
|
|
31
|
+
import bufferSplit from '../../utils/iterables/buffer-split.js';
|
|
32
|
+
import chain from '../../utils/iterables/chain.js';
|
|
33
|
+
|
|
34
|
+
const CRLF = Buffer.from('\r\n');
|
|
35
|
+
const CRLFCRLF = Buffer.from('\r\n\r\n');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parses a multipart/form-data body buffer according to RFC 7578.
|
|
39
|
+
*
|
|
40
|
+
* Each part is returned as an object with:
|
|
41
|
+
* - headers: parsed headers (content-disposition, content-type, etc.)
|
|
42
|
+
* - body: Buffer of the part body
|
|
43
|
+
* - name: shortcut to content-disposition name parameter
|
|
44
|
+
* - filename: shortcut to content-disposition filename parameter (if present)
|
|
45
|
+
*
|
|
46
|
+
* @param {import('node:buffer').Buffer|string} rawbody - the raw body buffer (from writer.js)
|
|
47
|
+
* @param {string} boundary - the multipart boundary string
|
|
48
|
+
* @param {object} [options] - Parser options
|
|
49
|
+
* @param {number} [options.maxParts=100] - Maximum number of parts to parse
|
|
50
|
+
* @returns {object[]} - array of parsed parts
|
|
51
|
+
*/
|
|
52
|
+
const DEFAULT_MAX_PARTS = 100;
|
|
53
|
+
|
|
54
|
+
export default (rawbody, boundary, {maxParts = DEFAULT_MAX_PARTS} = {}) => {
|
|
55
|
+
// Boundaries are prefixed with '--' per RFC 7578
|
|
56
|
+
const delimiter = Buffer.from(`--${boundary}`);
|
|
57
|
+
|
|
58
|
+
// Wrap in an array so bufferSplit iterable works over a single-chunk source
|
|
59
|
+
const source = [Buffer.isBuffer(rawbody) ? rawbody : Buffer.from(rawbody)];
|
|
60
|
+
|
|
61
|
+
// Split body on the boundary delimiter; yields [index, buffer] pairs
|
|
62
|
+
const parts = chain(source, bufferSplit(delimiter));
|
|
63
|
+
|
|
64
|
+
const results = [];
|
|
65
|
+
|
|
66
|
+
for (const [index, partBuf] of parts) {
|
|
67
|
+
// index 0 is preamble (before first boundary), skip it
|
|
68
|
+
// The last part will be just '--\r\n' (closing boundary) or empty, skip it too
|
|
69
|
+
if (index === 0) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Each part buffer starts with \r\n after the boundary delimiter
|
|
74
|
+
// and may end with \r\n before the next boundary delimiter
|
|
75
|
+
// Strip leading CRLF
|
|
76
|
+
let content = partBuf;
|
|
77
|
+
if (content.slice(0, CRLF.length).equals(CRLF)) {
|
|
78
|
+
content = content.slice(CRLF.length);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Strip trailing CRLF
|
|
82
|
+
if (content.slice(-CRLF.length).equals(CRLF)) {
|
|
83
|
+
content = content.slice(0, -CRLF.length);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The closing boundary part ends with '--'; skip it
|
|
87
|
+
if (content.length >= 2 && content[0] === 0x2d && content[1] === 0x2d) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (results.length >= maxParts) break;
|
|
92
|
+
|
|
93
|
+
// Split headers from body on the first CRLFCRLF sequence
|
|
94
|
+
const separatorIdx = indexOfSequence(content, CRLFCRLF);
|
|
95
|
+
|
|
96
|
+
if (separatorIdx === -1) {
|
|
97
|
+
// Malformed part: no header/body separator
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const headerSection = content.slice(0, separatorIdx);
|
|
102
|
+
const body = content.slice(separatorIdx + CRLFCRLF.length);
|
|
103
|
+
|
|
104
|
+
// Split header section into individual header lines
|
|
105
|
+
const headerLines = splitBuffer(headerSection, CRLF).map(b => b.toString());
|
|
106
|
+
|
|
107
|
+
const headers = parseHeaders(headerLines);
|
|
108
|
+
|
|
109
|
+
const disposition = headers['content-disposition'] ?? {};
|
|
110
|
+
const params = disposition.parameters ?? {};
|
|
111
|
+
|
|
112
|
+
results.push({
|
|
113
|
+
headers,
|
|
114
|
+
name: params.name,
|
|
115
|
+
filename: params.filename,
|
|
116
|
+
body
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return results;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Find the first occurrence of a sequence (needle) in a buffer (haystack).
|
|
125
|
+
* Returns the index of the first byte of the sequence, or -1 if not found.
|
|
126
|
+
*
|
|
127
|
+
* @param {import('node:buffer').Buffer} haystack - Buffer to search within
|
|
128
|
+
* @param {import('node:buffer').Buffer} needle - Byte sequence to find
|
|
129
|
+
* @returns {number} - Index of the first match, or -1
|
|
130
|
+
*/
|
|
131
|
+
function indexOfSequence(haystack, needle) {
|
|
132
|
+
const nLen = needle.length;
|
|
133
|
+
outer: for (let i = 0; i <= haystack.length - nLen; i++) {
|
|
134
|
+
for (let j = 0; j < nLen; j++) {
|
|
135
|
+
if (haystack[i + j] !== needle[j]) {
|
|
136
|
+
continue outer;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return i;
|
|
140
|
+
}
|
|
141
|
+
return -1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Split a buffer by a separator buffer, returning an array of Buffer slices.
|
|
146
|
+
*
|
|
147
|
+
* @param {import('node:buffer').Buffer} buf - Buffer to split
|
|
148
|
+
* @param {import('node:buffer').Buffer} sep - Separator byte sequence
|
|
149
|
+
* @returns {import('node:buffer').Buffer[]} - Array of Buffer slices between separators
|
|
150
|
+
*/
|
|
151
|
+
function splitBuffer(buf, sep) {
|
|
152
|
+
const results = [];
|
|
153
|
+
let start = 0;
|
|
154
|
+
const sLen = sep.length;
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i <= buf.length - sLen; i++) {
|
|
157
|
+
let match = true;
|
|
158
|
+
for (let j = 0; j < sLen; j++) {
|
|
159
|
+
if (buf[i + j] !== sep[j]) {
|
|
160
|
+
match = false;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (match) {
|
|
165
|
+
results.push(buf.slice(start, i));
|
|
166
|
+
start = i + sLen;
|
|
167
|
+
i += sLen - 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
results.push(buf.slice(start));
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Multipart MIME header parser for RFC 7578 parts.
|
|
3
|
+
*
|
|
4
|
+
* Parses an array of header line strings from a multipart/form-data part into a structured
|
|
5
|
+
* object. Extracts parameter key-value pairs from headers with directives (e.g.
|
|
6
|
+
* `Content-Disposition: form-data; name="file"; filename="upload.txt"`).
|
|
7
|
+
*
|
|
8
|
+
* Only the headers explicitly allowed by RFC 7578 §4.8 are retained:
|
|
9
|
+
* - `content-disposition`
|
|
10
|
+
* - `content-type`
|
|
11
|
+
* - `content-transfer-encoding` (deprecated but present in legacy clients)
|
|
12
|
+
*
|
|
13
|
+
* @module lib/body/multipart/headers
|
|
14
|
+
* @version 0.1.0
|
|
15
|
+
* @since 0.1.0
|
|
16
|
+
* @requires ../../../utils/iterables/exec-all.js
|
|
17
|
+
*
|
|
18
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc7578 RFC 7578 - Returning Values from Forms: multipart/form-data}
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* import parseHeaders from 'ergo/lib/body/multipart/headers';
|
|
22
|
+
*
|
|
23
|
+
* parseHeaders(['Content-Disposition: form-data; name="field1"']);
|
|
24
|
+
* // => {'content-disposition': {type: 'form-data', parameters: {name: 'field1'}}}
|
|
25
|
+
*/
|
|
26
|
+
import execAll from '../../../utils/iterables/exec-all.js';
|
|
27
|
+
|
|
28
|
+
const headerRegExp = /^([^:]+):\s*([^;]+);?\s*(.*?)$/;
|
|
29
|
+
const directivesRegExp = /\s*([^=]+)=\s*(?:"([^"]+)"|([^;\s]+));?/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Allowed headers are restricted to:
|
|
33
|
+
* Content-Disposition https://www.rfc-editor.org/rfc/rfc7578#section-4.2,
|
|
34
|
+
* Content-Type https://www.rfc-editor.org/rfc/rfc7578#section-4.4
|
|
35
|
+
* and Content-Transfer-Encoding (deprecated) https://www.rfc-editor.org/rfc/rfc7578#section-4.7.
|
|
36
|
+
*
|
|
37
|
+
* https://www.rfc-editor.org/rfc/rfc7578#section-4.8
|
|
38
|
+
*/
|
|
39
|
+
const allowed = ['content-disposition', 'content-type', 'content-transfer-encoding'];
|
|
40
|
+
|
|
41
|
+
const reAll = execAll(directivesRegExp);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Array<import('node:buffer').Buffer|string>} [buffers=[]] - Array of header line buffers from a multipart part
|
|
45
|
+
* @param {object} [headers] - Initial headers object; defaults to `{content-type: {type: 'text/plain'}}` when omitted
|
|
46
|
+
* @returns {object} - Parsed headers keyed by lowercase header name with `{type, parameters}` values
|
|
47
|
+
*/
|
|
48
|
+
export default (buffers = [], headers) => {
|
|
49
|
+
const base =
|
|
50
|
+
headers !== undefined
|
|
51
|
+
? Object.assign(Object.create(null), headers)
|
|
52
|
+
: Object.assign(Object.create(null), {'content-type': {type: 'text/plain'}});
|
|
53
|
+
return buffers
|
|
54
|
+
.map(buffer => headerRegExp.exec(buffer.toString().trim()))
|
|
55
|
+
.filter(m => m !== null)
|
|
56
|
+
.reduce((obj, [, prop, type, parameters]) => {
|
|
57
|
+
const lcProp = prop.toLowerCase();
|
|
58
|
+
|
|
59
|
+
if (allowed.includes(lcProp)) {
|
|
60
|
+
const params = Object.create(null);
|
|
61
|
+
for (const [k, quoted, unquoted] of reAll(parameters)) {
|
|
62
|
+
params[k] = quoted ?? unquoted;
|
|
63
|
+
}
|
|
64
|
+
obj[lcProp] = {type, parameters: params};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return obj;
|
|
68
|
+
}, base);
|
|
69
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Writable stream accumulator for collecting request body chunks.
|
|
3
|
+
*
|
|
4
|
+
* Creates a `Writable` stream that buffers all incoming `Buffer` chunks and consolidates
|
|
5
|
+
* them into a single `Buffer` when the stream finishes. Exposed via the `.data` getter.
|
|
6
|
+
*
|
|
7
|
+
* Used by `http/body.js` in the 3-stream decompression pipeline:
|
|
8
|
+
* `pipeline(req, meter, decompressor, writer())`
|
|
9
|
+
*
|
|
10
|
+
* @module lib/body/writer
|
|
11
|
+
* @version 0.1.0
|
|
12
|
+
* @since 0.1.0
|
|
13
|
+
* @requires node:stream
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import {pipeline} from 'node:stream';
|
|
17
|
+
* import writer from 'ergo/lib/body/writer';
|
|
18
|
+
*
|
|
19
|
+
* const w = writer();
|
|
20
|
+
* pipeline(readable, w, err => {
|
|
21
|
+
* if (!err) console.log(w.data); // => Buffer with all accumulated bytes
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
import {Writable} from 'node:stream';
|
|
25
|
+
|
|
26
|
+
const chunksSym = Symbol('chunks');
|
|
27
|
+
const bytesSym = Symbol('bytes');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a Writable stream that accumulates chunks into a single Buffer.
|
|
31
|
+
*
|
|
32
|
+
* @returns {import('node:stream').Writable} - Writable stream with a `.data` getter for the concatenated Buffer (available after stream ends)
|
|
33
|
+
*/
|
|
34
|
+
export default () => {
|
|
35
|
+
const w = Object.defineProperties(new Writable({write, final}), {
|
|
36
|
+
[chunksSym]: {
|
|
37
|
+
value: [],
|
|
38
|
+
writable: true
|
|
39
|
+
},
|
|
40
|
+
[bytesSym]: {
|
|
41
|
+
value: 0,
|
|
42
|
+
writable: true
|
|
43
|
+
},
|
|
44
|
+
data: {
|
|
45
|
+
get() {
|
|
46
|
+
return this[chunksSym];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return w;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {import('node:buffer').Buffer} chunk - Incoming data chunk
|
|
56
|
+
* @param {string} encoding - Chunk encoding (ignored for Buffers)
|
|
57
|
+
* @param {function} cb - Callback to signal write completion
|
|
58
|
+
*/
|
|
59
|
+
function write(chunk, encoding, cb) {
|
|
60
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
|
61
|
+
this[chunksSym].push(buf);
|
|
62
|
+
this[bytesSym] += buf.length;
|
|
63
|
+
cb(null);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {function} cb - Callback to signal finalization is complete
|
|
68
|
+
*/
|
|
69
|
+
function final(cb) {
|
|
70
|
+
// Consolidate all chunks into a single Buffer
|
|
71
|
+
this[chunksSym] = Buffer.concat(this[chunksSym], this[bytesSym]);
|
|
72
|
+
cb(null);
|
|
73
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Cookie construction factory (RFC 6265 compliant).
|
|
3
|
+
*
|
|
4
|
+
* Creates typed cookie objects with a `toHeader()` method that serializes the cookie
|
|
5
|
+
* to a `Set-Cookie` header string per RFC 6265.
|
|
6
|
+
*
|
|
7
|
+
* Default directives enforce secure cookie practices: `Secure: true`, `HttpOnly: true`,
|
|
8
|
+
* `Path: /`. Cookies without a `value` (or where `value` is undefined) are set to expire
|
|
9
|
+
* immediately by defaulting `maxAge` to 0.
|
|
10
|
+
*
|
|
11
|
+
* @module lib/cookie/cookie
|
|
12
|
+
* @version 0.1.0
|
|
13
|
+
* @since 0.1.0
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import bake from 'ergo/lib/cookie/cookie';
|
|
17
|
+
*
|
|
18
|
+
* const c = bake('session', 'abc123', {maxAge: 3600, sameSite: 'Lax'});
|
|
19
|
+
* c.toHeader(); // 'session=abc123; Path=/; Max-Age=3600; SameSite=Lax; Secure; HttpOnly'
|
|
20
|
+
*
|
|
21
|
+
* // Expire a cookie by omitting value
|
|
22
|
+
* bake('session').toHeader(); // 'session=; Path=/; Max-Age=0; Expires=...; Secure; HttpOnly'
|
|
23
|
+
*
|
|
24
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc6265 RFC 6265 - HTTP State Management Mechanism}
|
|
25
|
+
*/
|
|
26
|
+
export default bake;
|
|
27
|
+
|
|
28
|
+
const dough = Object.create(
|
|
29
|
+
{},
|
|
30
|
+
{
|
|
31
|
+
toHeader: {
|
|
32
|
+
value() {
|
|
33
|
+
return toHeader(this);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
isCookie: {
|
|
37
|
+
value: true
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A cookie factory function.
|
|
44
|
+
* @param {string} name - The name of the cookie.
|
|
45
|
+
* @param {string} [value] - The value of the cookie. If undefined
|
|
46
|
+
* as well as expires and maxAge are undefined, then the cookie will be set to expire.
|
|
47
|
+
* @param {object} [directives] - See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies|Cookies} for more information about directives.
|
|
48
|
+
* @param {string} [directives.domain] - The domain of the cookie. Defaults to
|
|
49
|
+
* the host portion of the current document location. If a domain is specified,
|
|
50
|
+
* subdomains are always included.
|
|
51
|
+
* @param {string} [directives.path='/'] - The absolute path of the cookie. Defaults
|
|
52
|
+
* to the current path of the current document location.
|
|
53
|
+
* @param {boolean} [directives.secure=true] - Indicates whether the cookie is
|
|
54
|
+
* transmitted over secure protocols such as HTTPS.
|
|
55
|
+
* @param {boolean} [directives.httpOnly=true] - Indicates whether the cookie is
|
|
56
|
+
* accessible via client JavaScript (e.g. document.cookie, Request, XMLHttpRequest, etc.).
|
|
57
|
+
* @param {'lax'|'strict'|'none'} [directives.sameSite] - Indicates if a cookie shouldn't be sent
|
|
58
|
+
* with cross-site requests. See {@link https://www.owasp.org/index.php/SameSite|SameSite} for more information.
|
|
59
|
+
* @param {number} [directives.maxAge] - The maximum age of a cookie in seconds.
|
|
60
|
+
* @param {(Date|string|number)} [directives.expires] - The GMT timestamp of the cookie
|
|
61
|
+
* expiration.
|
|
62
|
+
* @returns {object} - A cookie object with name, value, directives, and toHeader().
|
|
63
|
+
*/
|
|
64
|
+
function bake(
|
|
65
|
+
name,
|
|
66
|
+
value,
|
|
67
|
+
{domain, path = '/', maxAge, expires, sameSite, secure = true, httpOnly = true} = {}
|
|
68
|
+
) {
|
|
69
|
+
// RFC 6265bis §5.4.7: SameSite=None requires the Secure attribute
|
|
70
|
+
const effectiveSecure =
|
|
71
|
+
sameSite !== undefined && String(sameSite).toLowerCase() === 'none' ? true : secure;
|
|
72
|
+
|
|
73
|
+
return Object.assign(Object.create(dough), {
|
|
74
|
+
name,
|
|
75
|
+
value,
|
|
76
|
+
domain,
|
|
77
|
+
path,
|
|
78
|
+
maxAge,
|
|
79
|
+
expires,
|
|
80
|
+
sameSite,
|
|
81
|
+
secure: effectiveSecure,
|
|
82
|
+
httpOnly
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Creates a new cookie header.
|
|
88
|
+
* @param {object} cookie - The cookie object.
|
|
89
|
+
* @param {string} [cookie.name] - The name of the cookie.
|
|
90
|
+
* @param {string} [cookie.value] - The value of the cookie. If undefined
|
|
91
|
+
* and expires/maxAge are undefined, the cookie will be set to expire.
|
|
92
|
+
* @param {string} [cookie.domain] - The domain of the cookie. Defaults to
|
|
93
|
+
* the host portion of the current document location. If a domain is specified,
|
|
94
|
+
* subdomains are always included.
|
|
95
|
+
* @param {string} [cookie.path] - The absolute path of the cookie. Defaults
|
|
96
|
+
* to the current path of the current document location.
|
|
97
|
+
* @param {boolean} [cookie.secure] - Indicates whether the cookie is
|
|
98
|
+
* transmitted over secure protocols such as HTTPS.
|
|
99
|
+
* @param {string} [cookie.sameSite] - Indicates if a cookie shouldn't be sent
|
|
100
|
+
* with cross-site requests. See {@link https://www.owasp.org/index.php/SameSite|SameSite} for more information.
|
|
101
|
+
* @param {boolean} [cookie.httpOnly] - Indicates whether the cookie is inaccessible
|
|
102
|
+
* to client-side JavaScript (e.g. `document.cookie`). See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies|HttpOnly} for more information.
|
|
103
|
+
* @param {number} [cookie.maxAge] - The maximum age of a cookie in seconds.
|
|
104
|
+
* @param {(Date|string|number)} [cookie.expires] - The GMT timestamp of the cookie
|
|
105
|
+
* expiration.
|
|
106
|
+
* @returns {string} - A serialized Set-Cookie header string.
|
|
107
|
+
*/
|
|
108
|
+
// RFC 6265 §4.1.1: cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
|
|
109
|
+
// Reject anything outside cookie-octet: SP, ", comma, semicolon, backslash, CTLs, DEL
|
|
110
|
+
// eslint-disable-next-line no-control-regex -- intentional: reject CTLs per RFC 6265 cookie-octet
|
|
111
|
+
const COOKIE_VALUE_UNSAFE_RE = /[\x00-\x20",;\\\x7f]/;
|
|
112
|
+
// RFC 7230 §3.2.6: token = 1*tchar (no CTLs, SP, or delimiters)
|
|
113
|
+
const TOKEN_RE = /^[!#$%&'*+\-.^_`|~\w]+$/;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Rejects cookie values containing characters outside RFC 6265 cookie-octet.
|
|
117
|
+
* @param {*} val - Field value to check
|
|
118
|
+
* @param {string} field - Field name for the error message
|
|
119
|
+
* @throws {TypeError} If the value contains forbidden characters
|
|
120
|
+
*/
|
|
121
|
+
function assertSafeValue(val, field) {
|
|
122
|
+
if (typeof val === 'string' && COOKIE_VALUE_UNSAFE_RE.test(val)) {
|
|
123
|
+
throw new TypeError(`Cookie ${field} contains forbidden characters`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Rejects cookie names that are not valid HTTP tokens per RFC 7230.
|
|
129
|
+
* @param {*} val - Name to check
|
|
130
|
+
* @throws {TypeError} If the name is not a valid token
|
|
131
|
+
*/
|
|
132
|
+
function assertSafeName(val) {
|
|
133
|
+
if (typeof val === 'string' && !TOKEN_RE.test(val)) {
|
|
134
|
+
throw new TypeError('Cookie name contains forbidden characters');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function toHeader({
|
|
139
|
+
name,
|
|
140
|
+
value,
|
|
141
|
+
domain,
|
|
142
|
+
path,
|
|
143
|
+
secure,
|
|
144
|
+
sameSite,
|
|
145
|
+
httpOnly,
|
|
146
|
+
maxAge = value === undefined ? 0 : undefined,
|
|
147
|
+
expires = value === undefined ? Date.now() + maxAge : undefined
|
|
148
|
+
} = {}) {
|
|
149
|
+
assertSafeName(name);
|
|
150
|
+
assertSafeValue(value, 'value');
|
|
151
|
+
assertSafeValue(domain, 'domain');
|
|
152
|
+
assertSafeValue(path, 'path');
|
|
153
|
+
|
|
154
|
+
let header = `${name === undefined ? '' : name}=${value === undefined ? '' : value}`;
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
* RFC 6265 uses OWS (optional whitespace) after semicolons; a space is
|
|
158
|
+
* conventional and maximizes compatibility with existing parsers.
|
|
159
|
+
* https://www.rfc-editor.org/rfc/rfc6265
|
|
160
|
+
*/
|
|
161
|
+
if (domain !== undefined) {
|
|
162
|
+
header += `; Domain=${domain}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (path !== undefined) {
|
|
166
|
+
header += `; Path=${path}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (maxAge !== undefined) {
|
|
170
|
+
header += `; Max-Age=${maxAge}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (expires !== undefined) {
|
|
174
|
+
header += `; Expires=${new Date(expires).toUTCString()}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
assertSafeValue(sameSite, 'sameSite');
|
|
178
|
+
|
|
179
|
+
if (sameSite !== undefined) {
|
|
180
|
+
header += `; SameSite=${sameSite}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (secure) {
|
|
184
|
+
header += '; Secure';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (httpOnly) {
|
|
188
|
+
header += '; HttpOnly';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return header;
|
|
192
|
+
}
|