@ti-engine/web-framework 1.32.0 → 1.33.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/CHANGELOG.md +40 -0
- package/components/local-user-directory.js +2 -1
- package/components/web-handlers.js +90 -19
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
This document will contain the list of changes made to the framework. The format is based on the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
|
4
4
|
|
|
5
|
+
## Version 1.33.0
|
|
6
|
+
|
|
7
|
+
* fix(web-handlers)!: refuse an OpenID Connect callback that cannot be completed through the normal error path
|
|
8
|
+
instead of answering `response.status(400).end()`. That was a bare status with an empty body: the visitor got a
|
|
9
|
+
blank page, nothing was logged, and the three distinct reasons a callback fails were collapsed into one
|
|
10
|
+
indistinguishable response. Whoever had to work out why had neither a message nor a log line to start from. A
|
|
11
|
+
refusal is now raised with an explicit `401`, so it presents exactly like every other sign-in failure — an
|
|
12
|
+
HTML `GET` lands back on the login page with `?error=<code>`, and an API client gets the standard payload.
|
|
13
|
+
Marked breaking because the status and body of a failed callback change; no caller should have been depending on
|
|
14
|
+
an empty 400, but a caller that was will see a `303` or a `401`.
|
|
15
|
+
* feat(web-handlers): distinguish the three failure reasons in the log, with the fact that identifies each.
|
|
16
|
+
`E_WEB_INVALID_REQUEST_QUERY` when the provider returned no authorization code, naming the provider's own
|
|
17
|
+
`error` (usually `access_denied` — the visitor declined consent). `E_SEC_INVALID_EXPIRED_SESSION` when the
|
|
18
|
+
session carries no OAuth state, **naming the host the callback arrived on and why that matters**: a session
|
|
19
|
+
cookie is host-scoped, so a sign-in begun on one hostname and called back on another arrives with no cookie and
|
|
20
|
+
therefore nothing to verify against. A service reachable under more than one name produces exactly this — a
|
|
21
|
+
platform that assigns both a generated and a deterministic hostname, say — and so does a session that expired
|
|
22
|
+
while the visitor sat on the consent screen. `E_SEC_UNAUTHORIZED_ACCESS` when the state does not match the one
|
|
23
|
+
issued. The distinction is in the log rather than the response, because the visitor has no use for it and an
|
|
24
|
+
attacker probing the endpoint should not be handed it. **Nothing secret is logged** — never the authorization
|
|
25
|
+
code, the state values, the PKCE verifier or the nonce — and a test pins that.
|
|
26
|
+
* fix(web-handlers): escape externally-supplied text before it reaches a log line or an error payload. The
|
|
27
|
+
previous two entries put two caller-controlled values into a `WARNING`: the provider's `error` query parameter
|
|
28
|
+
and the request's host headers. The callback endpoint is unprotected and accepts any query string, and query
|
|
29
|
+
parameters are percent-decoded before the handler sees them, so `%0A` arrives as a real newline. The console
|
|
30
|
+
appender writes one line per entry, which makes that newline the end of the line — everything after it reads as a
|
|
31
|
+
separate, entirely attacker-written entry, a forged `NOTICE - Sign-in succeeded for admin` sitting in the log
|
|
32
|
+
looking exactly like a real one. Every character outside printable ASCII is now rendered as a visible `\uXXXX`
|
|
33
|
+
escape and the value is capped at 100 characters, so a field stays diagnosable without being able to end the line
|
|
34
|
+
or flood the log. Escaping happens where the value enters rather than at each use, so nothing downstream can
|
|
35
|
+
reach the raw form. An allowlist of known OAuth error codes was the alternative and is worse: providers emit
|
|
36
|
+
non-standard codes, and the unfamiliar ones are precisely the ones worth reading. Raised by CodeRabbit on the
|
|
37
|
+
review of this branch.
|
|
38
|
+
* fix(web-handlers)!: refuse a callback whose session holds a PKCE verifier but no expected state, rather than
|
|
39
|
+
skipping the comparison. The guard was `oidc.state && state !== oidc.state`, so an absent expected state
|
|
40
|
+
disabled the check entirely. Nothing in the framework produces such a session — `authenticationHandler` writes
|
|
41
|
+
the verifier and the state together — but an unverifiable callback is not a callback to trust, and a guard that
|
|
42
|
+
silently turns itself off when its own input is missing is the wrong shape. `openid-client` checks
|
|
43
|
+
`expectedState` as well; this keeps the refusal where it can be explained.
|
|
44
|
+
|
|
5
45
|
## Version 1.32.0
|
|
6
46
|
|
|
7
47
|
* fix(authorization)!: `applyAdminRole` now reconciles the `admin` role in **both** directions — granted when the
|
|
@@ -233,7 +233,8 @@ function verifyPassword( password, encoded ) {
|
|
|
233
233
|
// Usernames that JavaScript's object model treats specially, rejected here because the storage layer this
|
|
234
234
|
// module writes through cannot represent all of them safely — reusing the exact trio ('__proto__',
|
|
235
235
|
// 'constructor', 'prototype') this codebase already treats as reserved at every other prototype-pollution
|
|
236
|
-
// boundary (see the CA-91 employee field-path guards in
|
|
236
|
+
// boundary (see the CA-91 employee field-path guards in the competence application, now its own repository).
|
|
237
|
+
// Verified empirically per name,
|
|
237
238
|
// not assumed uniformly:
|
|
238
239
|
// - '__proto__' is the one that corrupted storage, in @ti-engine/core **before 1.11.0**.
|
|
239
240
|
// `cache.instance.setJSON` serializes through `tools.stringifyJSON` —
|
|
@@ -94,6 +94,39 @@ const resolveHttpCode = ( exception ) => {
|
|
|
94
94
|
return exceptions.httpCode.C_500; // general, communication, or unknown → internal server error
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Longest run of externally-supplied text to put in one log line. Long enough that a real OAuth error code or
|
|
99
|
+
* hostname survives whole, short enough that nobody can flood the log through one field.
|
|
100
|
+
*
|
|
101
|
+
* @type {number}
|
|
102
|
+
*/
|
|
103
|
+
const LOG_VALUE_MAX_LENGTH = 100;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Renders a value that came from outside safe to interpolate into a log message or echo in an error payload.
|
|
107
|
+
* <br/>
|
|
108
|
+
* The console appender writes one line per entry, so a newline inside an interpolated value ends that line and
|
|
109
|
+
* everything after it reads as a separate, entirely attacker-written entry — a forged `NOTICE - sign-in succeeded`,
|
|
110
|
+
* say, sitting in the log looking exactly like a real one. Query parameters are percent-decoded before they reach
|
|
111
|
+
* us, so `%0A` arrives as a genuine line break.
|
|
112
|
+
* <br/>
|
|
113
|
+
* Every character outside printable ASCII becomes a visible `\uXXXX` escape rather than being dropped, so the value
|
|
114
|
+
* stays diagnosable — the point of logging it at all — while losing the ability to end the line. An allowlist of
|
|
115
|
+
* known OAuth error codes was the other option and is worse: providers emit non-standard codes, and the unfamiliar
|
|
116
|
+
* ones are precisely the ones worth reading.
|
|
117
|
+
*
|
|
118
|
+
* @method
|
|
119
|
+
* @param {*} value
|
|
120
|
+
* @returns {string}
|
|
121
|
+
* @private
|
|
122
|
+
*/
|
|
123
|
+
const sanitizeExternalValue = ( value ) => {
|
|
124
|
+
const escaped = String( value ?? "" ).replace( /[^\x20-\x7E]/g, ( character ) => {
|
|
125
|
+
return "\\u" + character.charCodeAt( 0 ).toString( 16 ).padStart( 4, "0" );
|
|
126
|
+
} );
|
|
127
|
+
return ( escaped.length > LOG_VALUE_MAX_LENGTH ) ? escaped.slice( 0, LOG_VALUE_MAX_LENGTH ) + "..." : escaped;
|
|
128
|
+
};
|
|
129
|
+
|
|
97
130
|
/**
|
|
98
131
|
* Used to assemble the current URL of a request.
|
|
99
132
|
*
|
|
@@ -356,7 +389,18 @@ module.exports.authenticationHandler = ( instance ) => {
|
|
|
356
389
|
};
|
|
357
390
|
|
|
358
391
|
/**
|
|
359
|
-
* Used to handle the callback from
|
|
392
|
+
* Used to handle the callback from an OpenID Connect provider.
|
|
393
|
+
* <br/>
|
|
394
|
+
* A callback that cannot be completed is refused through the normal error path — `next( … )` with an explicit
|
|
395
|
+
* `401` — so it presents exactly like every other sign-in failure: an HTML `GET` lands back on the login page with
|
|
396
|
+
* `?error=<code>`, and an API client gets the standard payload. It used to answer `response.status(400).end()`: a
|
|
397
|
+
* bare status with an empty body, no log line, and the three distinct reasons below collapsed into one blank page
|
|
398
|
+
* that said nothing to the person seeing it and nothing to whoever had to work out why.
|
|
399
|
+
* <br/>
|
|
400
|
+
* The three reasons are distinguished in the log rather than in the response, because the visitor has no use for
|
|
401
|
+
* the difference and an attacker probing the endpoint should not be handed it. Nothing secret is logged — never
|
|
402
|
+
* the authorization code, the state values, the PKCE verifier or the nonce — only the facts that identify which
|
|
403
|
+
* case this is.
|
|
360
404
|
*
|
|
361
405
|
* @method
|
|
362
406
|
* @param {TiWebServer} instance
|
|
@@ -368,27 +412,54 @@ module.exports.authorizedOAuth2CallbackHandler = ( instance, authMethod ) => {
|
|
|
368
412
|
return ( request, response, next ) => {
|
|
369
413
|
const code = request.query.code;
|
|
370
414
|
const state = request.query.state;
|
|
371
|
-
const oidc = request.session
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
415
|
+
const oidc = request.session?.oidc || {};
|
|
416
|
+
|
|
417
|
+
// The provider did not return an authorization code. Usually the visitor declined consent, or the provider
|
|
418
|
+
// refused the request outright; either way it says so in `error`, which is the single most useful thing to
|
|
419
|
+
// record and is not sensitive.
|
|
420
|
+
if ( !code ) {
|
|
421
|
+
const providerError = sanitizeExternalValue( request.query.error || "none" );
|
|
422
|
+
logger.log( `Refusing an OpenID sign-in via '${ authMethod }': the provider returned no authorization code (error='${ providerError }').`, logger.logSeverity.WARNING );
|
|
423
|
+
next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_QUERY, { detail: "The identity provider returned no authorization code.", providerError: providerError }, exceptions.httpCode.C_401 ) );
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
381
426
|
|
|
382
|
-
|
|
427
|
+
// The session holds nothing to verify the callback against. This is the case that used to be hardest to
|
|
428
|
+
// diagnose, so the log names the likely cause: a session cookie is scoped to one host, so a sign-in begun
|
|
429
|
+
// on one hostname and called back on another arrives with no cookie and therefore no state. A service
|
|
430
|
+
// reachable under more than one name — a platform that assigns both a generated and a deterministic
|
|
431
|
+
// hostname, say — produces exactly this, and so does a session that expired while the visitor sat on the
|
|
432
|
+
// provider's consent screen.
|
|
433
|
+
if ( !oidc.codeVerifier ) {
|
|
434
|
+
logger.log( `Refusing an OpenID sign-in via '${ authMethod }': the session carries no OAuth state. The callback arrived on host '${ sanitizeExternalValue( request.get( "x-forwarded-host" ) || request.get( "host" ) || "unknown" ) }' — a session cookie is host-scoped, so check that the sign-in began on this same host and that the session had not expired.`, logger.logSeverity.WARNING );
|
|
435
|
+
next( exceptions.raise( exceptions.exceptionCode.E_SEC_INVALID_EXPIRED_SESSION, { detail: "The session carries no OAuth state for this callback." }, exceptions.httpCode.C_401 ) );
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
383
438
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
} );
|
|
439
|
+
// The state does not match the one issued for this session. A missing expected state is refused too rather
|
|
440
|
+
// than skipped: a session holding a verifier but no state is inconsistent, and an unverifiable callback is
|
|
441
|
+
// not a callback to trust. (`openid-client` checks `expectedState` as well; this keeps the refusal here,
|
|
442
|
+
// where it can be explained.)
|
|
443
|
+
if ( !oidc.state || state !== oidc.state ) {
|
|
444
|
+
logger.log( `Refusing an OpenID sign-in via '${ authMethod }': the callback's state does not match the one issued for this session.`, logger.logSeverity.WARNING );
|
|
445
|
+
next( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, { detail: "The callback state does not match the one issued for this session." }, exceptions.httpCode.C_401 ) );
|
|
446
|
+
return;
|
|
391
447
|
}
|
|
448
|
+
|
|
449
|
+
instance.authorize( authMethod, new URL( request.originalUrl, getBaseUrl( request ) ), oidc ).then( ( user ) => {
|
|
450
|
+
return regenerateAndSaveSession( request, "/", ( session ) => {
|
|
451
|
+
session.user = user.asJSON();
|
|
452
|
+
session.language = user.language || instance.serviceConfig.language;
|
|
453
|
+
|
|
454
|
+
delete session.oidc;
|
|
455
|
+
|
|
456
|
+
return authorization.applyAdminRole( instance.augmentSession( session, request ), instance.serviceConfig?.auth?.admins );
|
|
457
|
+
} );
|
|
458
|
+
} ).then( ( redirectTo ) => {
|
|
459
|
+
response.redirect( exceptions.httpCode.C_303, convertUriToString( redirectTo ) );
|
|
460
|
+
} ).catch( ( error ) => {
|
|
461
|
+
next( exceptions.raise( error, null, exceptions.httpCode.C_401 ) );
|
|
462
|
+
} );
|
|
392
463
|
};
|
|
393
464
|
};
|
|
394
465
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ti-engine/web-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.0",
|
|
4
4
|
"description": "A web-framework based on the ti-engine. It provides a customizable ready-to-use web-server microservice and a set of tools for creating web applications. NOTICE: This is still a work in progress and the full architecture, design, and functionality are not available!",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ti-engine",
|