@blamejs/core 0.14.18 → 0.14.20
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 +4 -0
- package/README.md +2 -2
- package/lib/archive.js +206 -52
- package/lib/auth/oauth.js +794 -1
- package/lib/auth/oid4vci.js +84 -27
- package/lib/auth/sd-jwt-vc-holder.js +46 -1
- package/lib/crypto-field.js +274 -17
- package/lib/mail-auth.js +887 -55
- package/lib/middleware/fetch-metadata.js +115 -14
- package/lib/middleware/scim-server.js +294 -10
- package/lib/middleware/security-headers.js +47 -0
- package/lib/observability.js +39 -1
- package/lib/openapi-paths-builder.js +105 -29
- package/lib/openapi.js +225 -100
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -43,6 +43,52 @@ var observability = lazyRequire(function () { return require("../observability")
|
|
|
43
43
|
|
|
44
44
|
var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
|
|
45
45
|
|
|
46
|
+
// Sec-Fetch-Dest request-destination vocabulary (Fetch Standard §3.2.6
|
|
47
|
+
// "destination", https://fetch.spec.whatwg.org/#concept-request-destination;
|
|
48
|
+
// surfaced as the Sec-Fetch-Dest header by the Fetch Metadata Request
|
|
49
|
+
// Headers spec, https://www.w3.org/TR/fetch-metadata/#sec-fetch-dest-header).
|
|
50
|
+
// "webidentity" (FedCM credentialed-request destination,
|
|
51
|
+
// https://w3c.github.io/FedCM/) is included so an operator can recognize
|
|
52
|
+
// and gate FedCM traffic first-class — a webidentity Sec-Fetch-Dest on a
|
|
53
|
+
// route that is not a FedCM identity endpoint is a request worth refusing.
|
|
54
|
+
var KNOWN_DESTINATIONS = Object.freeze([
|
|
55
|
+
"audio", "audioworklet", "document", "embed", "empty", "fencedframe",
|
|
56
|
+
"font", "frame", "iframe", "image", "json", "manifest", "object",
|
|
57
|
+
"paintworklet", "report", "script", "serviceworker", "sharedworker",
|
|
58
|
+
"style", "track", "video", "webidentity", "worker", "xslt",
|
|
59
|
+
]);
|
|
60
|
+
var KNOWN_DEST_SET = Object.create(null);
|
|
61
|
+
(function () {
|
|
62
|
+
for (var i = 0; i < KNOWN_DESTINATIONS.length; i += 1) {
|
|
63
|
+
KNOWN_DEST_SET[KNOWN_DESTINATIONS[i]] = true;
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
|
|
67
|
+
// Sec-Fetch-Storage-Access status values (Storage Access API,
|
|
68
|
+
// https://privacycg.github.io/storage-access-headers/ — the header is
|
|
69
|
+
// distinct from Sec-Fetch-Dest). The browser sends this only on cross-site
|
|
70
|
+
// credentialed requests. "active" / "inactive" both indicate the embedded
|
|
71
|
+
// context can (active) or could (inactive, permission granted but not yet
|
|
72
|
+
// exercised) reach unpartitioned cross-site cookies; "none" carries no
|
|
73
|
+
// such capability. A route that does not participate in the Storage Access
|
|
74
|
+
// flow may refuse the active/inactive escalation.
|
|
75
|
+
var STORAGE_ACCESS_ESCALATED = Object.freeze({ active: true, inactive: true });
|
|
76
|
+
|
|
77
|
+
function _validateDestList(list, label) {
|
|
78
|
+
// Config-time tier — an unknown Sec-Fetch-Dest value in a strict
|
|
79
|
+
// allow/deny list is almost always an operator typo (e.g. "web-identity"
|
|
80
|
+
// for "webidentity"). Throw at boot per the config/entry-point tier so
|
|
81
|
+
// the typo surfaces before it silently fails to match at request time.
|
|
82
|
+
if (!Array.isArray(list)) return;
|
|
83
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
84
|
+
if (!KNOWN_DEST_SET[list[i]]) {
|
|
85
|
+
throw new Error("middleware.fetchMetadata: " + label + "[" + i +
|
|
86
|
+
"] is not a known Sec-Fetch-Dest value (got '" + String(list[i]) +
|
|
87
|
+
"'). Known destinations: " + KNOWN_DESTINATIONS.join(", ") + ".");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
46
92
|
function _writeReject(req, res, message, reason, onDeny, problemMode) {
|
|
47
93
|
denyResponse(req, res, {
|
|
48
94
|
onDeny: onDeny,
|
|
@@ -75,17 +121,30 @@ function _writeReject(req, res, message, reason, onDeny, problemMode) {
|
|
|
75
121
|
* the value-add; non-browser callers carry their own auth threat
|
|
76
122
|
* model.
|
|
77
123
|
*
|
|
124
|
+
* The Sec-Fetch-Dest vocabulary tracks the Fetch Standard request-
|
|
125
|
+
* destination list, including `webidentity` (FedCM credentialed
|
|
126
|
+
* requests). `deniedDest` refuses chosen destinations outright on the
|
|
127
|
+
* gated methods — a FedCM `webidentity` Sec-Fetch-Dest hitting a route
|
|
128
|
+
* that is not an identity endpoint is refused. `allowStorageAccess:
|
|
129
|
+
* false` refuses the Storage Access API escalation (a cross-site request
|
|
130
|
+
* carrying `Sec-Fetch-Storage-Access: active` / `inactive`) on routes
|
|
131
|
+
* that do not participate in the Storage Access flow. Both are opt-in;
|
|
132
|
+
* leaving them unset preserves the prior behavior exactly.
|
|
133
|
+
*
|
|
78
134
|
* @opts
|
|
79
135
|
* {
|
|
80
|
-
* allowSameSite:
|
|
81
|
-
* allowCrossSite:
|
|
82
|
-
* allowMissing:
|
|
83
|
-
* allowedDest:
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
136
|
+
* allowSameSite: boolean, // default true
|
|
137
|
+
* allowCrossSite: boolean, // default false
|
|
138
|
+
* allowMissing: boolean, // default true
|
|
139
|
+
* allowedDest: string[], // cross-site allowlist of Sec-Fetch-Dest values
|
|
140
|
+
* deniedDest: string[], // Sec-Fetch-Dest values refused on gated methods regardless of site (e.g. ["webidentity"])
|
|
141
|
+
* allowStorageAccess: boolean, // default true — false refuses Sec-Fetch-Storage-Access: active|inactive
|
|
142
|
+
* strictDest: boolean, // default false — true throws at config time on an allowedDest/deniedDest value outside the known Sec-Fetch-Dest vocabulary
|
|
143
|
+
* allowedNavigate: boolean, // default true
|
|
144
|
+
* methods: string[], // default POST/PUT/DELETE/PATCH
|
|
145
|
+
* audit: boolean, // default true
|
|
146
|
+
* onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
|
|
147
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
89
148
|
* }
|
|
90
149
|
*
|
|
91
150
|
* @example
|
|
@@ -102,15 +161,34 @@ function create(opts) {
|
|
|
102
161
|
opts = opts || {};
|
|
103
162
|
validateOpts(opts, [
|
|
104
163
|
"allowSameSite", "allowCrossSite", "allowMissing",
|
|
105
|
-
"allowedDest", "
|
|
164
|
+
"allowedDest", "deniedDest", "allowStorageAccess", "strictDest",
|
|
165
|
+
"allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
|
|
106
166
|
], "middleware.fetchMetadata");
|
|
167
|
+
validateOpts.optionalBoolean(opts.allowStorageAccess, "middleware.fetchMetadata: allowStorageAccess");
|
|
168
|
+
validateOpts.optionalBoolean(opts.strictDest, "middleware.fetchMetadata: strictDest");
|
|
169
|
+
validateOpts.optionalNonEmptyStringArray(opts.deniedDest, "middleware.fetchMetadata: deniedDest");
|
|
170
|
+
if (opts.strictDest === true) {
|
|
171
|
+
_validateDestList(opts.allowedDest, "allowedDest");
|
|
172
|
+
_validateDestList(opts.deniedDest, "deniedDest");
|
|
173
|
+
}
|
|
107
174
|
|
|
108
175
|
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
109
176
|
var problemMode = opts.problemDetails === true;
|
|
110
|
-
var allowSameSite
|
|
111
|
-
var allowCrossSite
|
|
112
|
-
var allowMissing
|
|
113
|
-
var allowedDest
|
|
177
|
+
var allowSameSite = opts.allowSameSite !== false;
|
|
178
|
+
var allowCrossSite = opts.allowCrossSite === true;
|
|
179
|
+
var allowMissing = opts.allowMissing !== false;
|
|
180
|
+
var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
|
|
181
|
+
var allowStorageAccess = opts.allowStorageAccess !== false;
|
|
182
|
+
// deniedDest → a null-prototype membership map; an operator-supplied
|
|
183
|
+
// destination string is never assigned onto a plain object, so no
|
|
184
|
+
// reserved name (__proto__ / constructor / prototype) can pollute it.
|
|
185
|
+
var deniedDest = null;
|
|
186
|
+
if (Array.isArray(opts.deniedDest) && opts.deniedDest.length > 0) {
|
|
187
|
+
deniedDest = Object.create(null);
|
|
188
|
+
for (var di = 0; di < opts.deniedDest.length; di += 1) {
|
|
189
|
+
deniedDest[opts.deniedDest[di]] = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
114
192
|
var allowedNavigate = opts.allowedNavigate !== false;
|
|
115
193
|
var methods = (opts.methods || DEFAULT_METHODS).map(function (m) { return m.toUpperCase(); });
|
|
116
194
|
var auditOn = opts.audit !== false;
|
|
@@ -139,6 +217,16 @@ function create(opts) {
|
|
|
139
217
|
var mode = headers["sec-fetch-mode"];
|
|
140
218
|
var dest = headers["sec-fetch-dest"];
|
|
141
219
|
|
|
220
|
+
// Destination refusal — independent of site. A FedCM `webidentity`
|
|
221
|
+
// (or any operator-denied) Sec-Fetch-Dest on a route that is not an
|
|
222
|
+
// identity endpoint is refused outright. The membership test is exact
|
|
223
|
+
// (null-prototype map keyed on the verbatim header value), never a
|
|
224
|
+
// substring scan.
|
|
225
|
+
if (deniedDest && typeof dest === "string" && deniedDest[dest] === true) {
|
|
226
|
+
_emitDenied(req, "dest-denied (dest=" + dest + ")");
|
|
227
|
+
return _writeReject(req, res, "Request destination not allowed for this route.", "dest-not-allowed", onDeny, problemMode);
|
|
228
|
+
}
|
|
229
|
+
|
|
142
230
|
if (typeof site !== "string" || site.length === 0) {
|
|
143
231
|
// No Sec-Fetch-Site header — legacy browser or non-browser client.
|
|
144
232
|
// Defer to other auth/CSRF layers per allowMissing.
|
|
@@ -165,6 +253,19 @@ function create(opts) {
|
|
|
165
253
|
}
|
|
166
254
|
|
|
167
255
|
// cross-site
|
|
256
|
+
// Storage Access API escalation — the browser sends
|
|
257
|
+
// Sec-Fetch-Storage-Access only on cross-site credentialed requests.
|
|
258
|
+
// active|inactive both mean the embedded context can / could reach
|
|
259
|
+
// unpartitioned cross-site cookies; refuse it on routes that do not
|
|
260
|
+
// participate in the Storage Access flow. Exact membership, never a
|
|
261
|
+
// substring scan. Checked before the allowCrossSite shortcut so the
|
|
262
|
+
// escalation is gated even when cross-site is otherwise permitted.
|
|
263
|
+
var storageAccess = headers["sec-fetch-storage-access"];
|
|
264
|
+
if (!allowStorageAccess && typeof storageAccess === "string" &&
|
|
265
|
+
STORAGE_ACCESS_ESCALATED[storageAccess] === true) {
|
|
266
|
+
_emitDenied(req, "storage-access-refused (status=" + storageAccess + ")");
|
|
267
|
+
return _writeReject(req, res, "Storage Access escalation not allowed for this route.", "storage-access-refused", onDeny, problemMode);
|
|
268
|
+
}
|
|
168
269
|
if (allowCrossSite) return next();
|
|
169
270
|
if (allowedDest && typeof dest === "string" && allowedDest.indexOf(dest) !== -1) {
|
|
170
271
|
return next();
|
|
@@ -19,8 +19,17 @@ var ScimServerError = framework_error.defineClass(
|
|
|
19
19
|
|
|
20
20
|
var SCIM_CORE_SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
|
|
21
21
|
var SCIM_CORE_SCHEMA_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group";
|
|
22
|
-
var SCIM_MESSAGE_ERROR
|
|
23
|
-
var SCIM_MESSAGE_LIST
|
|
22
|
+
var SCIM_MESSAGE_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
|
|
23
|
+
var SCIM_MESSAGE_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
|
24
|
+
var SCIM_MESSAGE_BULK_REQUEST = "urn:ietf:params:scim:api:messages:2.0:BulkRequest";
|
|
25
|
+
var SCIM_MESSAGE_BULK_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:BulkResponse";
|
|
26
|
+
|
|
27
|
+
// RFC 7644 §3.7.2 — a bulkId cross-reference is the literal token
|
|
28
|
+
// "bulkId:" followed by the client-assigned identifier. Bounded: the
|
|
29
|
+
// identifier is matched lazily-free (one or more non-quote chars) and
|
|
30
|
+
// only ever applied to operator-defined bulkId strings, never to free
|
|
31
|
+
// request text, so there is no super-linear backtracking exposure.
|
|
32
|
+
var BULK_ID_REF_RE = /^bulkId:(.+)$/;
|
|
24
33
|
|
|
25
34
|
var ALLOWED_FILTER_OPS = ["eq", "ne", "co", "sw", "ew", "pr", "gt", "ge", "lt", "le"];
|
|
26
35
|
|
|
@@ -37,6 +46,11 @@ var BEARER_RE = /^Bearer\s+(.+)$/i;
|
|
|
37
46
|
* Returns a request middleware that handles SCIM 2.0 requests
|
|
38
47
|
* (RFC 7642-7644). Operator supplies CRUD callbacks per resource.
|
|
39
48
|
*
|
|
49
|
+
* Bulk operations (RFC 7644 §3.7) are opt-in: pass `opts.bulk` to
|
|
50
|
+
* enable the `/Bulk` POST endpoint and advertise it in
|
|
51
|
+
* ServiceProviderConfig. When omitted, `bulk.supported` stays `false`
|
|
52
|
+
* and `/Bulk` is not routed (back-compatible default).
|
|
53
|
+
*
|
|
40
54
|
* @opts
|
|
41
55
|
* {
|
|
42
56
|
* basePath?: string,
|
|
@@ -44,6 +58,10 @@ var BEARER_RE = /^Bearer\s+(.+)$/i;
|
|
|
44
58
|
* groups?: ScimResourceImpl,
|
|
45
59
|
* bearer?: (token) => Promise<actor>,
|
|
46
60
|
* maxPageSize?: number,
|
|
61
|
+
* bulk?: {
|
|
62
|
+
* maxOperations?: number, // default: 1000 (config-time positive int)
|
|
63
|
+
* maxPayloadSize?: number, // default: 1 MiB (config-time positive int, bytes)
|
|
64
|
+
* },
|
|
47
65
|
* }
|
|
48
66
|
*
|
|
49
67
|
* @example
|
|
@@ -51,6 +69,7 @@ var BEARER_RE = /^Bearer\s+(.+)$/i;
|
|
|
51
69
|
* basePath: "/scim/v2",
|
|
52
70
|
* users: myUserAdapter,
|
|
53
71
|
* groups: myGroupAdapter,
|
|
72
|
+
* bulk: { maxOperations: 100 },
|
|
54
73
|
* });
|
|
55
74
|
* app.use(mw);
|
|
56
75
|
*/
|
|
@@ -63,11 +82,12 @@ function create(opts) {
|
|
|
63
82
|
var basePath = opts.basePath || "/scim/v2";
|
|
64
83
|
var maxPageSize = opts.maxPageSize || 200; // page-size count, not bytes
|
|
65
84
|
var bearer = opts.bearer || null;
|
|
85
|
+
var bulkCfg = _resolveBulkConfig(opts.bulk);
|
|
66
86
|
|
|
67
87
|
function middleware(req, res, next) {
|
|
68
88
|
var url = req.url.split("?")[0];
|
|
69
89
|
if (url.indexOf(basePath) !== 0) { next(); return; }
|
|
70
|
-
_dispatch(req, res, basePath, bearer, opts, maxPageSize)
|
|
90
|
+
_dispatch(req, res, basePath, bearer, opts, maxPageSize, bulkCfg)
|
|
71
91
|
.catch(function (err) {
|
|
72
92
|
_writeScimError(res, err.statusCode || 500, err.scimType || "internal",
|
|
73
93
|
(err.message || String(err)).slice(0, 500));
|
|
@@ -92,7 +112,26 @@ function _validateResourceImpl(impl, name) {
|
|
|
92
112
|
});
|
|
93
113
|
}
|
|
94
114
|
|
|
95
|
-
|
|
115
|
+
// Config-time / entry-point tier: bad bulk caps THROW so the operator
|
|
116
|
+
// catches a typo at boot rather than at request time. RFC 7644 §3.7.
|
|
117
|
+
// Returns null when bulk is not opted into (back-compat default off).
|
|
118
|
+
function _resolveBulkConfig(bulk) {
|
|
119
|
+
if (bulk === undefined || bulk === null) return null;
|
|
120
|
+
if (typeof bulk !== "object") {
|
|
121
|
+
throw new ScimServerError("middleware/scim-server/bad-bulk",
|
|
122
|
+
"middleware.scimServer: opts.bulk must be an object { maxOperations?, maxPayloadSize? }");
|
|
123
|
+
}
|
|
124
|
+
validateOpts.optionalPositiveInt(bulk.maxOperations, "middleware.scimServer: opts.bulk.maxOperations",
|
|
125
|
+
ScimServerError, "middleware/scim-server/bad-bulk-max-operations");
|
|
126
|
+
validateOpts.optionalPositiveInt(bulk.maxPayloadSize, "middleware.scimServer: opts.bulk.maxPayloadSize",
|
|
127
|
+
ScimServerError, "middleware/scim-server/bad-bulk-max-payload-size");
|
|
128
|
+
return {
|
|
129
|
+
maxOperations: bulk.maxOperations || 1000,
|
|
130
|
+
maxPayloadSize: bulk.maxPayloadSize || C.BYTES.mib(1),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function _dispatch(req, res, basePath, bearer, opts, maxPageSize, bulkCfg) {
|
|
96
135
|
var relUrl = req.url.slice(basePath.length) || "/";
|
|
97
136
|
var qIdx = relUrl.indexOf("?");
|
|
98
137
|
var path = qIdx === -1 ? relUrl : relUrl.slice(0, qIdx);
|
|
@@ -148,6 +187,19 @@ async function _dispatch(req, res, basePath, bearer, opts, maxPageSize) {
|
|
|
148
187
|
var users = opts.users;
|
|
149
188
|
var groups = opts.groups;
|
|
150
189
|
|
|
190
|
+
if (path === "/Bulk") {
|
|
191
|
+
if (req.method !== "POST") {
|
|
192
|
+
_writeScimError(res, H.METHOD_NOT_ALLOWED, "noTarget", req.method + " not allowed on /Bulk");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!bulkCfg) {
|
|
196
|
+
_writeScimError(res, 501, "notSupported", "bulk operations are not enabled");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await _handleBulk(req, res, opts, bulkCfg, ctx);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
151
203
|
// RESOURCE_PATH_RE applies to a URL path; node http caps URL at 8 KiB.
|
|
152
204
|
var match = path.length < C.BYTES.kib(8) && RESOURCE_PATH_RE.test(path) ? path.match(RESOURCE_PATH_RE) : null; // allow:regex-no-length-cap URL bounded above
|
|
153
205
|
if (!match) {
|
|
@@ -244,6 +296,230 @@ async function _dispatch(req, res, basePath, bearer, opts, maxPageSize) {
|
|
|
244
296
|
_writeScimError(res, H.METHOD_NOT_ALLOWED, "noTarget", req.method + " not allowed on " + path);
|
|
245
297
|
}
|
|
246
298
|
|
|
299
|
+
// RFC 7644 §3.7 — /Bulk POST. Parses a BulkRequest, enforces the
|
|
300
|
+
// config-time maxOperations / maxPayloadSize caps, optionally
|
|
301
|
+
// short-circuits at failOnErrors, resolves bulkId cross-references
|
|
302
|
+
// (§3.7.2), dispatches each operation through the same per-resource
|
|
303
|
+
// create/update/delete logic the singleton endpoints use, and returns
|
|
304
|
+
// a BulkResponse carrying one result object per operation.
|
|
305
|
+
async function _handleBulk(req, res, opts, bulkCfg, ctx) {
|
|
306
|
+
var body;
|
|
307
|
+
try {
|
|
308
|
+
body = await _readBulkBody(req, bulkCfg.maxPayloadSize);
|
|
309
|
+
} catch (e) {
|
|
310
|
+
// collectStream rejects with the configured errorClass on overflow.
|
|
311
|
+
if (e && e.code === "middleware/scim-server/bulk-too-large") {
|
|
312
|
+
_writeScimError(res, H.PAYLOAD_TOO_LARGE, "tooLarge",
|
|
313
|
+
"bulk payload exceeds maxPayloadSize of " + bulkCfg.maxPayloadSize + " bytes");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
throw e;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!body || typeof body !== "object" ||
|
|
320
|
+
!Array.isArray(body.schemas) ||
|
|
321
|
+
body.schemas.indexOf(SCIM_MESSAGE_BULK_REQUEST) === -1) {
|
|
322
|
+
_writeScimError(res, H.BAD_REQUEST, "invalidValue",
|
|
323
|
+
"BulkRequest body.schemas must include '" + SCIM_MESSAGE_BULK_REQUEST + "'");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!Array.isArray(body.Operations)) {
|
|
327
|
+
_writeScimError(res, H.BAD_REQUEST, "invalidValue", "BulkRequest must include Operations[]");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (body.Operations.length > bulkCfg.maxOperations) {
|
|
331
|
+
_writeScimError(res, H.PAYLOAD_TOO_LARGE, "tooMany",
|
|
332
|
+
"bulk request has " + body.Operations.length +
|
|
333
|
+
" operations, exceeding maxOperations of " + bulkCfg.maxOperations);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
var failOnErrors = _parseFailOnErrors(body.failOnErrors);
|
|
338
|
+
var bulkIdMap = Object.create(null); // client bulkId -> assigned resource id
|
|
339
|
+
var results = [];
|
|
340
|
+
var errorCount = 0;
|
|
341
|
+
|
|
342
|
+
for (var i = 0; i < body.Operations.length; i++) {
|
|
343
|
+
var result = await _runBulkOperation(body.Operations[i], i, opts, ctx, bulkIdMap);
|
|
344
|
+
results.push(result.entry);
|
|
345
|
+
if (result.isError) {
|
|
346
|
+
errorCount++;
|
|
347
|
+
// RFC 7644 §3.7 — once the error count reaches failOnErrors the
|
|
348
|
+
// service stops processing and returns the results so far.
|
|
349
|
+
if (failOnErrors !== null && errorCount >= failOnErrors) break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
_writeJson(res, H.OK, {
|
|
354
|
+
schemas: [SCIM_MESSAGE_BULK_RESPONSE],
|
|
355
|
+
Operations: results,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// RFC 7644 §3.7 — failOnErrors is an OPTIONAL integer >= 1. Absent /
|
|
360
|
+
// non-conforming values mean "process every operation" (null).
|
|
361
|
+
function _parseFailOnErrors(value) {
|
|
362
|
+
if (typeof value !== "number" || !isFinite(value) || Math.floor(value) !== value || value < 1) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Dispatch one BulkOperation through the matching per-resource adapter.
|
|
369
|
+
// Returns { entry, isError }; entry is the BulkResponse Operation object
|
|
370
|
+
// (RFC 7644 §3.7). Adapter rejections become per-op error entries — one
|
|
371
|
+
// failing operation never aborts the whole batch (unless failOnErrors).
|
|
372
|
+
async function _runBulkOperation(op, index, opts, ctx, bulkIdMap) {
|
|
373
|
+
if (!op || typeof op !== "object" || typeof op.method !== "string") {
|
|
374
|
+
return _bulkErr(op, "400", "invalidSyntax",
|
|
375
|
+
"Operations[" + index + "] missing string method");
|
|
376
|
+
}
|
|
377
|
+
var method = op.method.toUpperCase();
|
|
378
|
+
if (method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") {
|
|
379
|
+
return _bulkErr(op, "400", "invalidValue",
|
|
380
|
+
"Operations[" + index + "].method '" + op.method + "' not in POST/PUT/PATCH/DELETE");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
var parsed = _parseBulkPath(op.path);
|
|
384
|
+
if (!parsed) {
|
|
385
|
+
return _bulkErr(op, "400", "invalidValue",
|
|
386
|
+
"Operations[" + index + "].path '" + String(op.path) + "' is not a valid bulk path");
|
|
387
|
+
}
|
|
388
|
+
var impl = parsed.resourceType === "Users" ? opts.users : opts.groups;
|
|
389
|
+
if (!impl) {
|
|
390
|
+
return _bulkErr(op, "404", "invalidValue", "/" + parsed.resourceType + " not configured");
|
|
391
|
+
}
|
|
392
|
+
// POST defines a bulkId-assigned resource; PUT/PATCH/DELETE target an id.
|
|
393
|
+
if (method === "POST" && !op.bulkId) {
|
|
394
|
+
return _bulkErr(op, "400", "invalidValue",
|
|
395
|
+
"Operations[" + index + "] POST requires a bulkId");
|
|
396
|
+
}
|
|
397
|
+
if (method !== "POST" && !parsed.resourceId) {
|
|
398
|
+
return _bulkErr(op, "400", "invalidValue",
|
|
399
|
+
"Operations[" + index + "] " + method + " requires a resource id in path");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
var data = op.data;
|
|
403
|
+
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
404
|
+
// RFC 7644 §3.7.2 — substitute any bulkId cross-references that
|
|
405
|
+
// earlier operations have resolved before handing data to the adapter.
|
|
406
|
+
data = _resolveBulkIdRefs(op.data, bulkIdMap);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
if (method === "POST" || method === "PUT") {
|
|
411
|
+
// The same SCIM schema gate the singleton POST / PUT routes apply,
|
|
412
|
+
// so a bulk op cannot persist a resource with a missing or wrong
|
|
413
|
+
// schema that the singleton endpoints would reject (RFC 7644
|
|
414
|
+
// §3.5.1). A throw here is caught below and returned as the op's
|
|
415
|
+
// own error, not aborting the batch.
|
|
416
|
+
_assertSchema(data, parsed.resourceType === "Users" ? SCIM_CORE_SCHEMA_USER : SCIM_CORE_SCHEMA_GROUP);
|
|
417
|
+
}
|
|
418
|
+
if (method === "POST") {
|
|
419
|
+
var created = await impl.create(data || {}, ctx);
|
|
420
|
+
var newId = created && created.id;
|
|
421
|
+
if (op.bulkId && newId !== undefined && newId !== null) {
|
|
422
|
+
bulkIdMap[String(op.bulkId)] = String(newId);
|
|
423
|
+
}
|
|
424
|
+
return _bulkOk(op, "201", _bulkLocation(parsed.resourceType, newId), created);
|
|
425
|
+
}
|
|
426
|
+
if (method === "PUT") {
|
|
427
|
+
var replaced = await impl.update(parsed.resourceId, data || {}, ctx);
|
|
428
|
+
return _bulkOk(op, "200", _bulkLocation(parsed.resourceType, parsed.resourceId), replaced);
|
|
429
|
+
}
|
|
430
|
+
if (method === "PATCH") {
|
|
431
|
+
var ops = data && Array.isArray(data.Operations) ? data.Operations : [];
|
|
432
|
+
var patched = await impl.patch(parsed.resourceId, ops, ctx);
|
|
433
|
+
return _bulkOk(op, "200", _bulkLocation(parsed.resourceType, parsed.resourceId), patched);
|
|
434
|
+
}
|
|
435
|
+
await impl.remove(parsed.resourceId, ctx); // DELETE
|
|
436
|
+
return _bulkOk(op, "204", _bulkLocation(parsed.resourceType, parsed.resourceId), null);
|
|
437
|
+
} catch (e) {
|
|
438
|
+
var status = e && e.statusCode ? String(e.statusCode) : "500";
|
|
439
|
+
var scimType = e && e.scimType ? e.scimType : null;
|
|
440
|
+
// Operator-adapter error detail is surfaced verbatim (it is the
|
|
441
|
+
// adapter's own message, not request-derived secret material).
|
|
442
|
+
return _bulkErr(op, status, scimType, (e && e.message) ? String(e.message) : "operation failed");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// A bulk path is "/Users", "/Users/<id>", "/Groups", or "/Groups/<id>".
|
|
447
|
+
function _parseBulkPath(path) {
|
|
448
|
+
if (typeof path !== "string" || path.length === 0) return null;
|
|
449
|
+
// Reuse the singleton resource-path grammar; node http caps URL at 8 KiB.
|
|
450
|
+
var m = path.length < C.BYTES.kib(8) && RESOURCE_PATH_RE.test(path) // allow:regex-no-length-cap path bounded above
|
|
451
|
+
? path.match(RESOURCE_PATH_RE) : null;
|
|
452
|
+
if (!m) return null;
|
|
453
|
+
return { resourceType: m[1], resourceId: m[2] || null };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// RFC 7644 §3.7.2 — walk operation data and replace any string of the
|
|
457
|
+
// form "bulkId:<clientId>" with the server-assigned id once known.
|
|
458
|
+
// Operates only on operator/client-supplied bulk data of bounded size
|
|
459
|
+
// (the whole payload is capped at maxPayloadSize before parse).
|
|
460
|
+
function _resolveBulkIdRefs(value, bulkIdMap) {
|
|
461
|
+
if (typeof value === "string") {
|
|
462
|
+
var ref = BULK_ID_REF_RE.exec(value);
|
|
463
|
+
if (ref) {
|
|
464
|
+
var resolved = bulkIdMap[ref[1]];
|
|
465
|
+
return resolved !== undefined ? resolved : value;
|
|
466
|
+
}
|
|
467
|
+
return value;
|
|
468
|
+
}
|
|
469
|
+
if (Array.isArray(value)) {
|
|
470
|
+
var arr = [];
|
|
471
|
+
for (var i = 0; i < value.length; i++) arr.push(_resolveBulkIdRefs(value[i], bulkIdMap));
|
|
472
|
+
return arr;
|
|
473
|
+
}
|
|
474
|
+
if (value && typeof value === "object") {
|
|
475
|
+
var out = {};
|
|
476
|
+
var keys = Object.keys(value);
|
|
477
|
+
for (var k = 0; k < keys.length; k++) {
|
|
478
|
+
if (keys[k] === "__proto__" || keys[k] === "constructor" || keys[k] === "prototype") continue;
|
|
479
|
+
out[keys[k]] = _resolveBulkIdRefs(value[keys[k]], bulkIdMap);
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
return value;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function _bulkLocation(resourceType, id) {
|
|
487
|
+
if (id === undefined || id === null) return undefined;
|
|
488
|
+
return "/" + resourceType + "/" + String(id);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function _bulkOk(op, status, location, response) {
|
|
492
|
+
var entry = { method: op && op.method, status: status };
|
|
493
|
+
if (op && op.bulkId) entry.bulkId = op.bulkId;
|
|
494
|
+
if (location) entry.location = location;
|
|
495
|
+
// Per §3.7 the response body is OPTIONAL; include it when the adapter
|
|
496
|
+
// returned a representation (omit on 204 No Content).
|
|
497
|
+
if (response !== null && response !== undefined) entry.response = response;
|
|
498
|
+
return { entry: entry, isError: false };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function _bulkErr(op, status, scimType, detail) {
|
|
502
|
+
var errBody = { schemas: [SCIM_MESSAGE_ERROR], status: status, detail: detail };
|
|
503
|
+
if (scimType) errBody.scimType = scimType;
|
|
504
|
+
var entry = { method: op && op.method, status: status, response: errBody };
|
|
505
|
+
if (op && op.bulkId) entry.bulkId = op.bulkId;
|
|
506
|
+
return { entry: entry, isError: true };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function _readBulkBody(req, maxBytes) {
|
|
510
|
+
if (req.body && Buffer.isBuffer(req.body)) {
|
|
511
|
+
return Promise.resolve(safeJson.parse(req.body.toString("utf8"), { maxBytes: maxBytes }));
|
|
512
|
+
}
|
|
513
|
+
if (req.body && typeof req.body === "object") return Promise.resolve(req.body);
|
|
514
|
+
return safeBuffer.collectStream(req, {
|
|
515
|
+
maxBytes: maxBytes,
|
|
516
|
+
errorClass: ScimServerError,
|
|
517
|
+
sizeCode: "middleware/scim-server/bulk-too-large",
|
|
518
|
+
}).then(function (buf) {
|
|
519
|
+
return safeJson.parse(buf.toString("utf8"), { maxBytes: maxBytes });
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
247
523
|
function _parseQuery(qs) {
|
|
248
524
|
var out = {};
|
|
249
525
|
if (!qs) return out;
|
|
@@ -321,11 +597,17 @@ function _writeScimError(res, status, scimType, detail) {
|
|
|
321
597
|
}
|
|
322
598
|
|
|
323
599
|
function _serviceProviderConfig(opts) {
|
|
600
|
+
// RFC 7644 §3.7 — advertise bulk only when the operator opted in via
|
|
601
|
+
// opts.bulk; otherwise report it unsupported (back-compat default).
|
|
602
|
+
var bulkCfg = _resolveBulkConfig(opts.bulk);
|
|
603
|
+
var bulk = bulkCfg
|
|
604
|
+
? { supported: true, maxOperations: bulkCfg.maxOperations, maxPayloadSize: bulkCfg.maxPayloadSize }
|
|
605
|
+
: { supported: false, maxOperations: 0, maxPayloadSize: 0 };
|
|
324
606
|
return {
|
|
325
607
|
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
326
608
|
documentationUri: opts.documentationUri || "https://datatracker.ietf.org/doc/html/rfc7643",
|
|
327
609
|
patch: { supported: true },
|
|
328
|
-
bulk:
|
|
610
|
+
bulk: bulk,
|
|
329
611
|
filter: { supported: true, maxResults: opts.maxPageSize || 200 },
|
|
330
612
|
changePassword: { supported: false },
|
|
331
613
|
sort: { supported: true },
|
|
@@ -370,9 +652,11 @@ function _schemas() {
|
|
|
370
652
|
}
|
|
371
653
|
|
|
372
654
|
module.exports = {
|
|
373
|
-
create:
|
|
374
|
-
ScimServerError:
|
|
375
|
-
SCIM_CORE_SCHEMA_USER:
|
|
376
|
-
SCIM_CORE_SCHEMA_GROUP:
|
|
377
|
-
|
|
655
|
+
create: create,
|
|
656
|
+
ScimServerError: ScimServerError,
|
|
657
|
+
SCIM_CORE_SCHEMA_USER: SCIM_CORE_SCHEMA_USER,
|
|
658
|
+
SCIM_CORE_SCHEMA_GROUP: SCIM_CORE_SCHEMA_GROUP,
|
|
659
|
+
SCIM_MESSAGE_BULK_REQUEST: SCIM_MESSAGE_BULK_REQUEST,
|
|
660
|
+
SCIM_MESSAGE_BULK_RESPONSE: SCIM_MESSAGE_BULK_RESPONSE,
|
|
661
|
+
ALLOWED_FILTER_OPS: ALLOWED_FILTER_OPS,
|
|
378
662
|
};
|
|
@@ -34,6 +34,18 @@
|
|
|
34
34
|
* dnsPrefetchControl: 'off' (default) or 'on' or false
|
|
35
35
|
* csp: '<full CSP string>' or false to disable
|
|
36
36
|
* }
|
|
37
|
+
*
|
|
38
|
+
* Monitor-mode opt-ins (all default-off; unset emits no new header):
|
|
39
|
+
*
|
|
40
|
+
* coopReportOnly / coepReportOnly / documentPolicyReportOnly — set a
|
|
41
|
+
* policy string to emit the matching `*-Report-Only` header so the
|
|
42
|
+
* operator can roll out the enforcing policy in monitor mode first.
|
|
43
|
+
* The browser reports violations (to a Reporting-Endpoints group named
|
|
44
|
+
* in the value, e.g. `same-origin; report-to="coop"`) without blocking.
|
|
45
|
+
* requireDocumentPolicy — the embedder-required Document-Policy a
|
|
46
|
+
* subframe must advertise before this document will embed it.
|
|
47
|
+
* serviceWorkerAllowed — broadens the max scope a service worker
|
|
48
|
+
* registered from this script may claim (the operator opts in).
|
|
37
49
|
*/
|
|
38
50
|
|
|
39
51
|
var requestHelpers = require("../request-helpers");
|
|
@@ -186,6 +198,11 @@ function _validatePermissionsPolicy(value) {
|
|
|
186
198
|
* criticalCh: string|false,
|
|
187
199
|
* reportingEndpoints: object,
|
|
188
200
|
* trustProxy: boolean|number,
|
|
201
|
+
* coopReportOnly: string, // default: off — monitor-mode COOP
|
|
202
|
+
* coepReportOnly: string, // default: off — monitor-mode COEP
|
|
203
|
+
* documentPolicyReportOnly: string, // default: off — monitor-mode Document-Policy
|
|
204
|
+
* requireDocumentPolicy: string, // default: off — embedder-required subframe policy
|
|
205
|
+
* serviceWorkerAllowed: string, // default: off — broadens SW registration scope
|
|
189
206
|
* }
|
|
190
207
|
*
|
|
191
208
|
* @example
|
|
@@ -202,6 +219,8 @@ function create(opts) {
|
|
|
202
219
|
"permissionsPolicy", "coop", "coep", "corp",
|
|
203
220
|
"originAgentCluster", "dnsPrefetchControl", "csp", "trustProxy",
|
|
204
221
|
"reportingEndpoints", "documentPolicy", "criticalCh", "acceptCh",
|
|
222
|
+
"coopReportOnly", "coepReportOnly", "documentPolicyReportOnly",
|
|
223
|
+
"requireDocumentPolicy", "serviceWorkerAllowed",
|
|
205
224
|
], "middleware.securityHeaders");
|
|
206
225
|
if (opts.permissionsPolicy && typeof opts.permissionsPolicy === "string") {
|
|
207
226
|
_validatePermissionsPolicy(opts.permissionsPolicy);
|
|
@@ -222,6 +241,26 @@ function create(opts) {
|
|
|
222
241
|
var docPolicy = opts.documentPolicy === undefined ? DEFAULT_DOCUMENT_POLICY : opts.documentPolicy;
|
|
223
242
|
var criticalCh = opts.criticalCh && typeof opts.criticalCh === "string" ? opts.criticalCh : false;
|
|
224
243
|
var acceptCh = opts.acceptCh && typeof opts.acceptCh === "string" ? opts.acceptCh : false;
|
|
244
|
+
// Monitor-mode + scope opt-ins — all default-off. Each only emits its
|
|
245
|
+
// header when the operator passes a non-empty string; unset = silent.
|
|
246
|
+
// coopReportOnly / coepReportOnly — WHATWG HTML cross-origin isolation
|
|
247
|
+
// report-only variants: the UA evaluates the policy and reports
|
|
248
|
+
// violations to the named Reporting-Endpoints group without
|
|
249
|
+
// enforcing, so an operator can verify a same-origin / require-corp
|
|
250
|
+
// rollout won't break embeds before flipping the enforcing header.
|
|
251
|
+
// documentPolicyReportOnly — W3C Document Policy report-only variant
|
|
252
|
+
// (same monitor-mode semantics for the Document-Policy feature set).
|
|
253
|
+
// requireDocumentPolicy — W3C Document Policy: the policy a subframe
|
|
254
|
+
// must itself advertise (via Document-Policy) before this document
|
|
255
|
+
// will embed it; the embedder declares its floor.
|
|
256
|
+
// serviceWorkerAllowed — W3C Service Workers §Service-Worker-Allowed:
|
|
257
|
+
// widens the max scope a worker registered from this script may
|
|
258
|
+
// claim beyond the script's own path. Operator opts in explicitly.
|
|
259
|
+
var coopReportOnly = opts.coopReportOnly && typeof opts.coopReportOnly === "string" ? opts.coopReportOnly : false;
|
|
260
|
+
var coepReportOnly = opts.coepReportOnly && typeof opts.coepReportOnly === "string" ? opts.coepReportOnly : false;
|
|
261
|
+
var docPolicyReportOnly = opts.documentPolicyReportOnly && typeof opts.documentPolicyReportOnly === "string" ? opts.documentPolicyReportOnly : false;
|
|
262
|
+
var requireDocPolicy = opts.requireDocumentPolicy && typeof opts.requireDocumentPolicy === "string" ? opts.requireDocumentPolicy : false;
|
|
263
|
+
var serviceWorkerAllowed = opts.serviceWorkerAllowed && typeof opts.serviceWorkerAllowed === "string" ? opts.serviceWorkerAllowed : false;
|
|
225
264
|
// Reporting-Endpoints (W3C Reporting API) — when operator passes a
|
|
226
265
|
// map of endpoint-name → URL, we emit `Reporting-Endpoints: name="url",
|
|
227
266
|
// name2="url2", ...` and (when default CSP is in force) append
|
|
@@ -273,6 +312,14 @@ function create(opts) {
|
|
|
273
312
|
if (acceptCh) res.setHeader("Accept-CH", acceptCh);
|
|
274
313
|
if (criticalCh) res.setHeader("Critical-CH", criticalCh);
|
|
275
314
|
if (reportingEndpoints) res.setHeader("Reporting-Endpoints", reportingEndpoints);
|
|
315
|
+
// Monitor-mode + scope opt-ins — emitted only when the operator set
|
|
316
|
+
// the corresponding opt; the enforcing COOP/COEP/Document-Policy
|
|
317
|
+
// headers above are unaffected.
|
|
318
|
+
if (coopReportOnly) res.setHeader("Cross-Origin-Opener-Policy-Report-Only", coopReportOnly);
|
|
319
|
+
if (coepReportOnly) res.setHeader("Cross-Origin-Embedder-Policy-Report-Only", coepReportOnly);
|
|
320
|
+
if (docPolicyReportOnly) res.setHeader("Document-Policy-Report-Only", docPolicyReportOnly);
|
|
321
|
+
if (requireDocPolicy) res.setHeader("Require-Document-Policy", requireDocPolicy);
|
|
322
|
+
if (serviceWorkerAllowed) res.setHeader("Service-Worker-Allowed", serviceWorkerAllowed);
|
|
276
323
|
next();
|
|
277
324
|
};
|
|
278
325
|
}
|