cabloy 5.1.188 → 5.1.189
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/.cabloy-version +1 -1
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/repo-docs/backend/runtime-and-flavors.md +1 -1
- package/vona/docker-compose-original/config/nginx/conf.d/nginx.conf +2 -2
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/application.ts +12 -5
- package/vona/packages-vona/vona-core/src/lib/core/context.ts +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/src/suite/a-home/modules/home-user/src/service/account.ts +1 -1
- package/vona/src/suite/a-home/modules/home-user/test/accountPasswordReset.test.ts +24 -5
- package/vona/src/suite/a-home/modules/home-user/test/accountPasswordSet.test.ts +22 -5
- package/vona/src/suite-vendor/a-auth/modules/a-auth/package.json +1 -1
- package/vona/src/suite-vendor/a-auth/modules/a-auth/src/lib/utils.ts +42 -0
- package/vona/src/suite-vendor/a-auth/modules/a-auth/src/service/auth.ts +60 -2
- package/vona/src/suite-vendor/a-auth/modules/a-auth/test/authCallbackRedirect.test.ts +44 -0
- package/vona/src/suite-vendor/a-auth/modules/a-auth/test/oauthRequestContext.test.ts +171 -0
- package/vona/src/suite-vendor/a-auth/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-socket/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-socket/src/bean/socketConnection.cors.ts +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-security/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-security/src/bean/middlewareSystem.cors.ts +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/pnpm-lock.yaml +16 -16
package/.cabloy-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.1.
|
|
1
|
+
5.1.189
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.1.189
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Add anonymous context creation support.
|
|
8
|
+
- Add home user functionality.
|
|
9
|
+
- Add application host utility access through the context.
|
|
10
|
+
- Add authentication enhancements.
|
|
11
|
+
- Update core feature behavior.
|
|
12
|
+
|
|
3
13
|
## 5.1.188
|
|
4
14
|
|
|
5
15
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -133,7 +133,7 @@ Use this page as the runtime-facing overview, then inspect the current app confi
|
|
|
133
133
|
|
|
134
134
|
When configured, `SERVER_SERVE_PROTOCOL` and `SERVER_SERVE_HOST` define Vona's canonical public origin for backend APIs and backend-hosted SSR sites. If either serve value is not configured, Vona resolves the effective protocol and host from the current proxy-aware request context; background work that needs a stable backend public origin should configure both serve values explicitly.
|
|
135
135
|
|
|
136
|
-
A browser consumer can be deployed on a different origin and pathname. Account password-set and password-reset delivery
|
|
136
|
+
A browser consumer can be deployed on a different origin and pathname. Account password-set and password-reset delivery does not derive its destination from SSR Site metadata, `publicPath`, `siteId`, `Referer`, or browser-selected mount data: Zova supplies the complete token-free absolute public page URL, and Vona validates only HTTP(S), absence of userinfo/query/fragment, and an exact trusted origin. Production origin checks supply `app.util.host` as their same-host authority: when configured, `SERVER_SERVE_HOST` is the canonical host; otherwise Vona falls back to the current proxy-aware request host. `checkOriginExact` permits an origin whose protocol, canonical/fallback host, and effective port match; request or proxy Host values are not a general cross-origin allowlist and cannot authorize lookalike, suffix, alternate-scheme, or alternate-port destinations when a canonical host is configured. Other production consumer origins must exactly match an explicit `a-security:cors` `whiteList` entry. This change supplies the canonical host only; `checkOriginExact` continues to obtain the protocol from the active request context. In `dev/test`, exact credential-link authorization additionally accepts different ports only when both API and consumer hostnames are loopback (`localhost`, `127.0.0.1`, or `::1`). Vona preserves the frontend pathname and alone adds the raw token as the `token` URL query parameter. Query transport reaches the initial request; Referrer-Policy and Vona, edge, or APM query-log redaction are intentionally outside this flow's current scope. Normal CORS defaults to an empty `whiteList`; configure `CORS_WHITE_LIST` with the permitted browser origins, or explicitly configure `a-security:cors`. In `dev/test`, loopback requests may cross ports; elsewhere, cross-origin CORS requires an allowed entry. Normal CORS wildcard and suffix matching remain available when configured but are not credential-link authorization.
|
|
137
137
|
|
|
138
138
|
## Configure browser CORS origins
|
|
139
139
|
|
|
@@ -12,9 +12,9 @@ server {
|
|
|
12
12
|
proxy_http_version 1.1;
|
|
13
13
|
proxy_set_header X-Real-IP $remote_addr;
|
|
14
14
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
15
|
-
proxy_set_header X-Forwarded-Host $
|
|
15
|
+
proxy_set_header X-Forwarded-Host $host;
|
|
16
16
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
17
|
-
proxy_set_header Host $
|
|
17
|
+
proxy_set_header Host $host;
|
|
18
18
|
proxy_set_header X-NginX-Proxy true;
|
|
19
19
|
proxy_set_header Upgrade $http_upgrade;
|
|
20
20
|
proxy_set_header Connection "upgrade";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.100",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
|
|
6
6
|
"keywords": [
|
|
@@ -72,22 +72,29 @@ export class VonaApplication extends KoaApplication {
|
|
|
72
72
|
if (req) {
|
|
73
73
|
request = req;
|
|
74
74
|
} else {
|
|
75
|
-
const
|
|
75
|
+
const port = this.config?.server.listen.port ?? this.meta.env.SERVER_LISTEN_PORT;
|
|
76
|
+
const hostFallback = port ? `localhost:${port}` : 'localhost';
|
|
77
|
+
const protocol = (this.config ? this.util.protocol : undefined) || 'http';
|
|
78
|
+
const host = (this.config ? this.util.host : undefined) || hostFallback;
|
|
79
|
+
const hostname = new URL(`${protocol}://${host}`).hostname;
|
|
76
80
|
request = {
|
|
77
81
|
headers: {
|
|
78
82
|
'host': host,
|
|
79
|
-
'x-forwarded-for':
|
|
83
|
+
'x-forwarded-for': '127.0.0.1',
|
|
84
|
+
'x-forwarded-host': host,
|
|
85
|
+
'x-forwarded-proto': protocol,
|
|
80
86
|
},
|
|
81
87
|
query: {},
|
|
82
88
|
querystring: '',
|
|
83
89
|
host,
|
|
84
|
-
hostname
|
|
85
|
-
protocol
|
|
86
|
-
secure: '
|
|
90
|
+
hostname,
|
|
91
|
+
protocol,
|
|
92
|
+
secure: protocol === 'https',
|
|
87
93
|
method: 'POST',
|
|
88
94
|
url: '',
|
|
89
95
|
path: '',
|
|
90
96
|
socket: {
|
|
97
|
+
encrypted: protocol === 'https',
|
|
91
98
|
remoteAddress: '127.0.0.1',
|
|
92
99
|
remotePort: 7001,
|
|
93
100
|
},
|
|
@@ -127,7 +127,7 @@ export const contextBase: ContextBase = {
|
|
|
127
127
|
typeof statusOrOptions === 'number' ? { status: statusOrOptions } : (statusOrOptions ?? {});
|
|
128
128
|
// checkOrigin
|
|
129
129
|
if (!url.startsWith('/') && !options.trustedExternal) {
|
|
130
|
-
const origin = cast(self.app.bean).security.checkOrigin(url, self.host);
|
|
130
|
+
const origin = cast(self.app.bean).security.checkOrigin(url, self.app.util.host);
|
|
131
131
|
if (!origin) self.app.throw(403);
|
|
132
132
|
}
|
|
133
133
|
// throw
|
|
@@ -601,7 +601,7 @@ export class ServiceAccount extends BeanBase {
|
|
|
601
601
|
url.password ||
|
|
602
602
|
url.search ||
|
|
603
603
|
url.hash ||
|
|
604
|
-
this.bean.security.checkOriginExact(url.origin, this.
|
|
604
|
+
this.bean.security.checkOriginExact(url.origin, this.app.util.host) !== url.origin
|
|
605
605
|
) {
|
|
606
606
|
this.app.throw(503, `account ${purpose} consumer URL is invalid`);
|
|
607
607
|
}
|
|
@@ -248,21 +248,38 @@ describe('accountPasswordReset.test.ts', { concurrency: false }, () => {
|
|
|
248
248
|
}
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it('
|
|
251
|
+
it('uses the canonical host for an exact same-origin consumer without a whitelist entry', async () => {
|
|
252
252
|
let fixture: IAccountPasswordResetFixture | undefined;
|
|
253
253
|
let restoreConfig: (() => void) | undefined;
|
|
254
|
+
let serve: typeof app.config.server.serve | undefined;
|
|
255
|
+
let servePrevious: { protocol?: string; host?: string } | undefined;
|
|
254
256
|
try {
|
|
255
257
|
fixture = await createFixture({ simpleAuth: true });
|
|
256
258
|
restoreConfig = await configurePasswordReset([]);
|
|
259
|
+
await app.bean.executor.mockCtx(async () => {
|
|
260
|
+
serve = app.ctx.config.server.serve;
|
|
261
|
+
servePrevious = { ...serve };
|
|
262
|
+
(serve as any).host = 'canonical.example.test';
|
|
263
|
+
});
|
|
257
264
|
|
|
258
265
|
const sameOriginConsumerUrl = await app.bean.executor.mockCtx(async () => {
|
|
259
|
-
return `${app.ctx.protocol}://${app.
|
|
266
|
+
return `${app.ctx.protocol}://${app.util.host}/home/user/password-reset`;
|
|
260
267
|
});
|
|
261
268
|
const issued = await issuePasswordResetLink(fixture, sameOriginConsumerUrl);
|
|
262
269
|
assert.equal(issued.origin, new URL(sameOriginConsumerUrl).origin);
|
|
263
270
|
assert.equal(issued.path, '/home/user/password-reset');
|
|
264
271
|
assert.equal(issued.search, `?token=${issued.token}`);
|
|
272
|
+
|
|
273
|
+
await clearPasswordResetState(fixture.userId);
|
|
274
|
+
await clearPasswordResetRecipientCooldown(fixture.email);
|
|
275
|
+
const requestOriginConsumerUrl = await app.bean.executor.mockCtx(async () => {
|
|
276
|
+
return `${app.ctx.protocol}://${app.ctx.host}/home/user/password-reset`;
|
|
277
|
+
});
|
|
278
|
+
await requestPasswordReset(fixture.email, requestOriginConsumerUrl);
|
|
279
|
+
await assertNoPasswordResetState(fixture.userId);
|
|
280
|
+
await assertNoPasswordResetRecipientCooldown(fixture.email);
|
|
265
281
|
} finally {
|
|
282
|
+
if (serve && servePrevious) Object.assign(serve, servePrevious);
|
|
266
283
|
restoreConfig?.();
|
|
267
284
|
if (fixture) await removeFixture(fixture);
|
|
268
285
|
}
|
|
@@ -706,9 +723,11 @@ async function assertNoPasswordResetRecipientCooldown(email: string) {
|
|
|
706
723
|
}
|
|
707
724
|
|
|
708
725
|
async function clearPasswordResetState(userId: string): Promise<void> {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
726
|
+
await app.bean.executor.mockCtx(async () => {
|
|
727
|
+
const digest = await app.scope('home-user').cacheRedis.passwordResetCurrent.get(userId as any);
|
|
728
|
+
if (digest) await app.scope('home-user').cacheRedis.passwordReset.del(digest);
|
|
729
|
+
await app.scope('home-user').cacheRedis.passwordResetCurrent.del(userId as any);
|
|
730
|
+
});
|
|
712
731
|
}
|
|
713
732
|
|
|
714
733
|
async function assertNoPasswordResetState(userId: string, digest?: string): Promise<void> {
|
|
@@ -453,21 +453,36 @@ describe('accountPasswordSet.test.ts', { concurrency: false, sequential: true },
|
|
|
453
453
|
}
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
-
it('
|
|
456
|
+
it('uses the canonical host for an exact same-origin consumer without a whitelist entry', async () => {
|
|
457
457
|
let fixture: IAccountPasswordSetFixture | undefined;
|
|
458
458
|
let restoreConfig: (() => void) | undefined;
|
|
459
|
+
let serve: typeof app.config.server.serve | undefined;
|
|
460
|
+
let servePrevious: { protocol?: string; host?: string } | undefined;
|
|
459
461
|
try {
|
|
460
462
|
fixture = await createFixture();
|
|
461
463
|
restoreConfig = await configurePasswordSet([]);
|
|
464
|
+
await app.bean.executor.mockCtx(async () => {
|
|
465
|
+
serve = app.ctx.config.server.serve;
|
|
466
|
+
servePrevious = { ...serve };
|
|
467
|
+
(serve as any).host = 'canonical.example.test';
|
|
468
|
+
});
|
|
462
469
|
|
|
463
470
|
const sameOriginConsumerUrl = await app.bean.executor.mockCtx(async () => {
|
|
464
|
-
return `${app.ctx.protocol}://${app.
|
|
471
|
+
return `${app.ctx.protocol}://${app.util.host}/home/user/password-set`;
|
|
465
472
|
});
|
|
466
473
|
const issued = await issuePasswordSetLink(fixture, sameOriginConsumerUrl);
|
|
467
474
|
assert.equal(issued.origin, new URL(sameOriginConsumerUrl).origin);
|
|
468
475
|
assert.equal(issued.path, '/home/user/password-set');
|
|
469
476
|
assert.equal(issued.search, `?token=${issued.token}`);
|
|
477
|
+
|
|
478
|
+
await clearPasswordSetState(fixture.userId);
|
|
479
|
+
const requestOriginConsumerUrl = await app.bean.executor.mockCtx(async () => {
|
|
480
|
+
return `${app.ctx.protocol}://${app.ctx.host}/home/user/password-set`;
|
|
481
|
+
});
|
|
482
|
+
await issuePasswordSetLinkRejected(fixture, requestOriginConsumerUrl);
|
|
483
|
+
await assertNoPasswordSetState(fixture.userId);
|
|
470
484
|
} finally {
|
|
485
|
+
if (serve && servePrevious) Object.assign(serve, servePrevious);
|
|
471
486
|
restoreConfig?.();
|
|
472
487
|
if (fixture) await removeFixture(fixture);
|
|
473
488
|
}
|
|
@@ -784,9 +799,11 @@ async function getCacheTtl(cacheName: 'passwordSet' | 'passwordSetCurrent', key:
|
|
|
784
799
|
}
|
|
785
800
|
|
|
786
801
|
async function clearPasswordSetState(userId: string): Promise<void> {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
802
|
+
await app.bean.executor.mockCtx(async () => {
|
|
803
|
+
const digest = await app.scope('home-user').cacheRedis.passwordSetCurrent.get(userId);
|
|
804
|
+
if (digest) await app.scope('home-user').cacheRedis.passwordSet.del(digest);
|
|
805
|
+
await app.scope('home-user').cacheRedis.passwordSetCurrent.del(userId);
|
|
806
|
+
});
|
|
790
807
|
}
|
|
791
808
|
|
|
792
809
|
async function assertNoPasswordSetState(userId: string, digest?: string): Promise<void> {
|
|
@@ -7,3 +7,45 @@ export function getStrategyOauth2Profile<T = IAuthUserProfile>(
|
|
|
7
7
|
): T {
|
|
8
8
|
return args[2];
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
export type TypeOauthRedirectKind =
|
|
12
|
+
| 'absolute'
|
|
13
|
+
| 'invalid'
|
|
14
|
+
| 'missing'
|
|
15
|
+
| 'non_http'
|
|
16
|
+
| 'protocol_relative'
|
|
17
|
+
| 'relative';
|
|
18
|
+
|
|
19
|
+
export interface IOauthRedirectDiagnostic {
|
|
20
|
+
redirectKind: TypeOauthRedirectKind;
|
|
21
|
+
redirectOrigin?: string;
|
|
22
|
+
redirectProtocol?: string;
|
|
23
|
+
redirectHost?: string;
|
|
24
|
+
redirectHostname?: string;
|
|
25
|
+
redirectPort?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getOauthRedirectDiagnostic(redirect: string): IOauthRedirectDiagnostic {
|
|
29
|
+
if (redirect.startsWith('//')) return { redirectKind: 'protocol_relative' };
|
|
30
|
+
if (redirect.startsWith('/')) return { redirectKind: 'relative' };
|
|
31
|
+
|
|
32
|
+
let url: URL;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(redirect);
|
|
35
|
+
} catch {
|
|
36
|
+
return { redirectKind: 'invalid' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
40
|
+
return { redirectKind: 'non_http', redirectProtocol: url.protocol };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
redirectKind: 'absolute',
|
|
45
|
+
redirectOrigin: url.origin,
|
|
46
|
+
redirectProtocol: url.protocol,
|
|
47
|
+
redirectHost: url.host,
|
|
48
|
+
redirectHostname: url.hostname,
|
|
49
|
+
redirectPort: url.port || undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from '../types/authProvider.ts';
|
|
21
21
|
|
|
22
22
|
import { StrategyMock } from '../lib/strategyMock.ts';
|
|
23
|
+
import { getOauthRedirectDiagnostic } from '../lib/utils.ts';
|
|
23
24
|
|
|
24
25
|
@Service()
|
|
25
26
|
export class ServiceAuth extends BeanBase {
|
|
@@ -247,10 +248,27 @@ export class ServiceAuth extends BeanBase {
|
|
|
247
248
|
if (clientOptions.mockUsername) {
|
|
248
249
|
return resolve(jwt);
|
|
249
250
|
}
|
|
251
|
+
// redirect
|
|
252
|
+
if (!strategyState.redirect) {
|
|
253
|
+
this._logOauthRedirectRejected(entityAuthProvider, strategyState, 'redirect_missing');
|
|
254
|
+
return reject(new Error('redirect not specified'));
|
|
255
|
+
}
|
|
256
|
+
if (!strategyState.redirect.startsWith('/')) {
|
|
257
|
+
const redirectAllowed = !!this.bean.security.checkOrigin(
|
|
258
|
+
strategyState.redirect,
|
|
259
|
+
this.app.util.host,
|
|
260
|
+
);
|
|
261
|
+
if (!redirectAllowed) {
|
|
262
|
+
this._logOauthRedirectRejected(
|
|
263
|
+
entityAuthProvider,
|
|
264
|
+
strategyState,
|
|
265
|
+
'origin_disallowed',
|
|
266
|
+
);
|
|
267
|
+
return this.app.throw(403);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
250
270
|
// code
|
|
251
271
|
const code = await this.bean.passport.createOauthCodeFromOauthAuthToken(jwt.accessToken);
|
|
252
|
-
// redirect
|
|
253
|
-
if (!strategyState.redirect) return reject(new Error('redirect not specified'));
|
|
254
272
|
const redirectUrl = combineQueries(strategyState.redirect, {
|
|
255
273
|
[this.scope.config.oauthCodeField]: code,
|
|
256
274
|
});
|
|
@@ -266,6 +284,46 @@ export class ServiceAuth extends BeanBase {
|
|
|
266
284
|
});
|
|
267
285
|
}
|
|
268
286
|
|
|
287
|
+
private _logOauthRedirectRejected(
|
|
288
|
+
entityAuthProvider: EntityAuthProvider,
|
|
289
|
+
strategyState: IAuthenticateStrategyState,
|
|
290
|
+
reason: 'origin_disallowed' | 'redirect_missing',
|
|
291
|
+
) {
|
|
292
|
+
const redirectDiagnostic = strategyState.redirect
|
|
293
|
+
? getOauthRedirectDiagnostic(strategyState.redirect)
|
|
294
|
+
: { redirectKind: 'missing' as const };
|
|
295
|
+
const onionCors = this.bean.onion.middlewareSystem.getOnionSlice('a-security:cors');
|
|
296
|
+
const whiteList = onionCors?.beanOptions.options?.whiteList;
|
|
297
|
+
const whiteListEntries =
|
|
298
|
+
typeof whiteList === 'string' ? whiteList.split(',').filter(Boolean) : whiteList;
|
|
299
|
+
this.$logger.warn('OAuth callback final redirect rejected', {
|
|
300
|
+
event: 'auth.oauth_final_redirect_rejected',
|
|
301
|
+
stage: 'redirect_validation',
|
|
302
|
+
outcome: 'rejected',
|
|
303
|
+
reason,
|
|
304
|
+
authProviderId: entityAuthProvider.id,
|
|
305
|
+
providerName: entityAuthProvider.providerName,
|
|
306
|
+
clientName: entityAuthProvider.clientName,
|
|
307
|
+
intention: strategyState.intention ?? 'login',
|
|
308
|
+
instanceName: strategyState.instanceName,
|
|
309
|
+
mockMode: !!strategyState.clientOptions?.mockUsername,
|
|
310
|
+
requestHost: this.app.util.host,
|
|
311
|
+
requestProtocol: this.app.util.protocol,
|
|
312
|
+
serveHost: this.ctx.config.server.serve.host || null,
|
|
313
|
+
serveProtocol: this.ctx.config.server.serve.protocol || null,
|
|
314
|
+
proxyEnabled: this.app.proxy,
|
|
315
|
+
hostMatches:
|
|
316
|
+
redirectDiagnostic.redirectHost === undefined
|
|
317
|
+
? undefined
|
|
318
|
+
: redirectDiagnostic.redirectHost === this.app.util.host,
|
|
319
|
+
whiteListConfigured: Array.isArray(whiteList) ? whiteList.length > 0 : !!whiteList,
|
|
320
|
+
whiteListKind: Array.isArray(whiteList) ? 'array' : typeof whiteList,
|
|
321
|
+
whiteListEntryCount: Array.isArray(whiteListEntries) ? whiteListEntries.length : 0,
|
|
322
|
+
whiteListWildcard: whiteList === '*',
|
|
323
|
+
...redirectDiagnostic,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
269
327
|
public async getStrategyConstructable(
|
|
270
328
|
beanAuthProvider: IAuthProviderVerify & IAuthProviderStrategy,
|
|
271
329
|
clientOptions: IAuthProviderClientOptions,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { getOauthRedirectDiagnostic } from '../src/lib/utils.ts';
|
|
5
|
+
|
|
6
|
+
describe('authCallbackRedirect.test.ts', () => {
|
|
7
|
+
it('keeps absolute redirect diagnostics free of sensitive URL components', () => {
|
|
8
|
+
const code = 'oauth-authorization-code-sentinel';
|
|
9
|
+
const state = 'oauth-state-sentinel';
|
|
10
|
+
const token = 'oauth-token-sentinel';
|
|
11
|
+
const redirect = `https://user:password@app.example.test:8443/auth/callback?code=${code}&state=${state}&token=${token}#fragment-sentinel`;
|
|
12
|
+
|
|
13
|
+
const diagnostic = getOauthRedirectDiagnostic(redirect);
|
|
14
|
+
const serialized = JSON.stringify(diagnostic);
|
|
15
|
+
|
|
16
|
+
assert.deepEqual(diagnostic, {
|
|
17
|
+
redirectKind: 'absolute',
|
|
18
|
+
redirectOrigin: 'https://app.example.test:8443',
|
|
19
|
+
redirectProtocol: 'https:',
|
|
20
|
+
redirectHost: 'app.example.test:8443',
|
|
21
|
+
redirectHostname: 'app.example.test',
|
|
22
|
+
redirectPort: '8443',
|
|
23
|
+
});
|
|
24
|
+
for (const value of [code, state, token, 'user', 'password', 'callback', 'fragment-sentinel']) {
|
|
25
|
+
assert.equal(serialized.includes(value), false);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('classifies redirect shapes without retaining raw values', () => {
|
|
30
|
+
assert.deepEqual(getOauthRedirectDiagnostic('/home/base/authCallback?token=secret'), {
|
|
31
|
+
redirectKind: 'relative',
|
|
32
|
+
});
|
|
33
|
+
assert.deepEqual(getOauthRedirectDiagnostic('//app.example.test/authCallback?token=secret'), {
|
|
34
|
+
redirectKind: 'protocol_relative',
|
|
35
|
+
});
|
|
36
|
+
assert.deepEqual(getOauthRedirectDiagnostic('https://[invalid'), {
|
|
37
|
+
redirectKind: 'invalid',
|
|
38
|
+
});
|
|
39
|
+
assert.deepEqual(getOauthRedirectDiagnostic('javascript:alert(secret)'), {
|
|
40
|
+
redirectKind: 'non_http',
|
|
41
|
+
redirectProtocol: 'javascript:',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { after, before, describe, it } from 'node:test';
|
|
3
|
+
import { acquireTestLock, app } from 'vona-mock';
|
|
4
|
+
|
|
5
|
+
describe('oauthRequestContext.test.ts', { concurrency: false }, () => {
|
|
6
|
+
const releases: Array<() => void> = [];
|
|
7
|
+
|
|
8
|
+
before(async () => {
|
|
9
|
+
releases.push(await acquireTestLock('a-security'));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
after(() => {
|
|
13
|
+
for (const release of releases.reverse()) release();
|
|
14
|
+
});
|
|
15
|
+
it('passes the callback request through an isolated context without rewriting it', async () => {
|
|
16
|
+
await app.bean.executor.mockCtx(async () => {
|
|
17
|
+
const proxyPrevious = app.proxy;
|
|
18
|
+
app.proxy = true;
|
|
19
|
+
try {
|
|
20
|
+
const req = {
|
|
21
|
+
method: 'GET',
|
|
22
|
+
url: '/api/auth/passport/callback?a=1&b=2',
|
|
23
|
+
headers: {
|
|
24
|
+
'host': 'origin.internal.test',
|
|
25
|
+
'x-forwarded-host': 'demo.example.test',
|
|
26
|
+
'x-forwarded-proto': 'https',
|
|
27
|
+
'user-agent': 'oauth-callback-test',
|
|
28
|
+
},
|
|
29
|
+
httpVersionMajor: 1,
|
|
30
|
+
socket: {
|
|
31
|
+
remoteAddress: '192.0.2.10',
|
|
32
|
+
remotePort: 443,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const originalUrl = req.url;
|
|
36
|
+
const originalHeaders = { ...req.headers };
|
|
37
|
+
const outerContext = app.ctx;
|
|
38
|
+
const result = await app.bean.executor.newCtx(
|
|
39
|
+
async () => {
|
|
40
|
+
return {
|
|
41
|
+
context: app.ctx,
|
|
42
|
+
request: app.ctx.req,
|
|
43
|
+
method: app.ctx.method,
|
|
44
|
+
host: app.ctx.host,
|
|
45
|
+
protocol: app.ctx.protocol,
|
|
46
|
+
secure: app.ctx.secure,
|
|
47
|
+
query: app.ctx.request.query,
|
|
48
|
+
userAgent: app.ctx.get('user-agent'),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
instanceName: outerContext.instanceName!,
|
|
53
|
+
req,
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
assert.notEqual(result.context, outerContext);
|
|
58
|
+
assert.equal(result.request, req);
|
|
59
|
+
assert.equal(result.method, 'GET');
|
|
60
|
+
assert.equal(result.host, 'demo.example.test');
|
|
61
|
+
assert.equal(result.protocol, 'https');
|
|
62
|
+
assert.equal(result.secure, true);
|
|
63
|
+
assert.deepEqual(result.query, { a: '1', b: '2' });
|
|
64
|
+
assert.equal(result.userAgent, 'oauth-callback-test');
|
|
65
|
+
assert.equal(req.url, originalUrl);
|
|
66
|
+
assert.deepEqual(req.headers, originalHeaders);
|
|
67
|
+
assert.equal(app.ctx, outerContext);
|
|
68
|
+
} finally {
|
|
69
|
+
app.proxy = proxyPrevious;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('logs rejected redirects against the canonical server origin', async () => {
|
|
75
|
+
await app.bean.executor.mockCtx(async () => {
|
|
76
|
+
const proxyPrevious = app.proxy;
|
|
77
|
+
const serve = app.ctx.config.server.serve;
|
|
78
|
+
const servePrevious = { ...serve };
|
|
79
|
+
app.proxy = true;
|
|
80
|
+
(serve as any).host = 'canonical.example.test';
|
|
81
|
+
(serve as any).protocol = 'http';
|
|
82
|
+
try {
|
|
83
|
+
const req = {
|
|
84
|
+
method: 'GET',
|
|
85
|
+
url: '/api/auth/passport/callback',
|
|
86
|
+
headers: {
|
|
87
|
+
'host': 'origin.internal.test',
|
|
88
|
+
'x-forwarded-host': 'edge.example.test',
|
|
89
|
+
'x-forwarded-proto': 'https',
|
|
90
|
+
},
|
|
91
|
+
httpVersionMajor: 1,
|
|
92
|
+
socket: {
|
|
93
|
+
remoteAddress: '192.0.2.10',
|
|
94
|
+
remotePort: 443,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
await app.bean.executor.newCtx(
|
|
98
|
+
async () => {
|
|
99
|
+
assert.equal(app.ctx.host, 'edge.example.test');
|
|
100
|
+
assert.equal(app.ctx.protocol, 'https');
|
|
101
|
+
assert.equal(app.util.host, 'canonical.example.test');
|
|
102
|
+
assert.equal(app.util.protocol, 'http');
|
|
103
|
+
|
|
104
|
+
const service = app.scope('a-auth').service.auth;
|
|
105
|
+
const logger = (service as any).$logger;
|
|
106
|
+
const warnPrevious = logger.warn;
|
|
107
|
+
const warnings: unknown[][] = [];
|
|
108
|
+
logger.warn = (...args: unknown[]) => warnings.push(args);
|
|
109
|
+
try {
|
|
110
|
+
(service as any)._logOauthRedirectRejected(
|
|
111
|
+
{
|
|
112
|
+
id: -1,
|
|
113
|
+
providerName: 'auth-oauth:oauth',
|
|
114
|
+
clientName: 'github',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
authProviderId: -1,
|
|
118
|
+
instanceName: app.ctx.instanceName!,
|
|
119
|
+
locale: app.ctx.locale,
|
|
120
|
+
redirect: 'https://edge.example.test/oauth/callback',
|
|
121
|
+
},
|
|
122
|
+
'origin_disallowed',
|
|
123
|
+
);
|
|
124
|
+
} finally {
|
|
125
|
+
logger.warn = warnPrevious;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
assert.equal(warnings.length, 1);
|
|
129
|
+
assert.equal(warnings[0][0], 'OAuth callback final redirect rejected');
|
|
130
|
+
const diagnostic = warnings[0][1] as Record<string, unknown>;
|
|
131
|
+
assert.deepEqual(
|
|
132
|
+
{
|
|
133
|
+
event: diagnostic.event,
|
|
134
|
+
stage: diagnostic.stage,
|
|
135
|
+
outcome: diagnostic.outcome,
|
|
136
|
+
reason: diagnostic.reason,
|
|
137
|
+
requestHost: diagnostic.requestHost,
|
|
138
|
+
requestProtocol: diagnostic.requestProtocol,
|
|
139
|
+
serveHost: diagnostic.serveHost,
|
|
140
|
+
serveProtocol: diagnostic.serveProtocol,
|
|
141
|
+
proxyEnabled: diagnostic.proxyEnabled,
|
|
142
|
+
redirectHost: diagnostic.redirectHost,
|
|
143
|
+
hostMatches: diagnostic.hostMatches,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
event: 'auth.oauth_final_redirect_rejected',
|
|
147
|
+
stage: 'redirect_validation',
|
|
148
|
+
outcome: 'rejected',
|
|
149
|
+
reason: 'origin_disallowed',
|
|
150
|
+
requestHost: 'canonical.example.test',
|
|
151
|
+
requestProtocol: 'http',
|
|
152
|
+
serveHost: 'canonical.example.test',
|
|
153
|
+
serveProtocol: 'http',
|
|
154
|
+
proxyEnabled: true,
|
|
155
|
+
redirectHost: 'edge.example.test',
|
|
156
|
+
hostMatches: false,
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
instanceName: app.ctx.instanceName!,
|
|
162
|
+
req,
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
} finally {
|
|
166
|
+
app.proxy = proxyPrevious;
|
|
167
|
+
Object.assign(serve, servePrevious);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -14,7 +14,7 @@ export interface ISocketConnectionOptionsCors extends IDecoratorSocketConnection
|
|
|
14
14
|
export class SocketConnectionCors extends BeanBase implements ISocketConnectionExecute {
|
|
15
15
|
async enter(ws: WebSocket, _options: ISocketConnectionOptionsCors, next: Next): Promise<void> {
|
|
16
16
|
// cors
|
|
17
|
-
const origin = this.bean.security.checkOrigin(this.ctx.get('origin'), this.
|
|
17
|
+
const origin = this.bean.security.checkOrigin(this.ctx.get('origin'), this.app.util.host);
|
|
18
18
|
if (!origin) {
|
|
19
19
|
ws.terminate();
|
|
20
20
|
return;
|
|
@@ -52,5 +52,5 @@ export class MiddlewareSystemCors extends BeanBase implements IMiddlewareSystemE
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function _corsOrigin(ctx: VonaContext) {
|
|
55
|
-
return ctx.app.bean.security.checkOrigin(ctx.get('origin'), ctx.host);
|
|
55
|
+
return ctx.app.bean.security.checkOrigin(ctx.get('origin'), ctx.app.util.host);
|
|
56
56
|
}
|
package/zova/pnpm-lock.yaml
CHANGED
|
@@ -77,7 +77,7 @@ importers:
|
|
|
77
77
|
version: 10.6.0(postcss@8.5.28)
|
|
78
78
|
axios:
|
|
79
79
|
specifier: ^1.18.1
|
|
80
|
-
version: 1.20.0
|
|
80
|
+
version: 1.20.0(supports-color@10.2.2)
|
|
81
81
|
compression:
|
|
82
82
|
specifier: ^1.8.1
|
|
83
83
|
version: 1.8.2
|
|
@@ -369,7 +369,7 @@ importers:
|
|
|
369
369
|
devDependencies:
|
|
370
370
|
'@cabloy/cli':
|
|
371
371
|
specifier: ^3.1.29
|
|
372
|
-
version: 3.1.29(vue@3.5.42(typescript@5.9.3))
|
|
372
|
+
version: 3.1.29(supports-color@10.2.2)(vue@3.5.42(typescript@5.9.3))
|
|
373
373
|
'@cabloy/lint':
|
|
374
374
|
specifier: ^5.1.35
|
|
375
375
|
version: 5.1.35(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@5.9.3))(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.70.0(typescript@5.9.3))(@typescript-eslint/utils@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.10.0(jiti@2.7.0))(oxfmt@0.62.0)(oxlint@1.82.0)(supports-color@10.2.2)(typescript@5.9.3)(vue-eslint-parser@10.4.1(eslint@10.10.0(jiti@2.7.0)))
|
|
@@ -444,7 +444,7 @@ importers:
|
|
|
444
444
|
dependencies:
|
|
445
445
|
'@cabloy/cli':
|
|
446
446
|
specifier: ^3.1.29
|
|
447
|
-
version: 3.1.29(vue@3.5.42(typescript@5.9.3))
|
|
447
|
+
version: 3.1.29(supports-color@10.2.2)(vue@3.5.42(typescript@5.9.3))
|
|
448
448
|
'@cabloy/process-helper':
|
|
449
449
|
specifier: ^3.1.8
|
|
450
450
|
version: 3.1.8
|
|
@@ -455,7 +455,7 @@ importers:
|
|
|
455
455
|
specifier: ^7.6.2
|
|
456
456
|
version: 7.8.5
|
|
457
457
|
zova-cli-set-front:
|
|
458
|
-
specifier: ^1.2.
|
|
458
|
+
specifier: ^1.2.109
|
|
459
459
|
version: link:../cli-set-front
|
|
460
460
|
devDependencies:
|
|
461
461
|
clean-package:
|
|
@@ -478,7 +478,7 @@ importers:
|
|
|
478
478
|
version: 7.29.7(@babel/core@7.29.7)
|
|
479
479
|
'@cabloy/cli':
|
|
480
480
|
specifier: ^3.1.29
|
|
481
|
-
version: 3.1.29(vue@3.5.42(typescript@5.9.3))
|
|
481
|
+
version: 3.1.29(supports-color@10.2.2)(vue@3.5.42(typescript@5.9.3))
|
|
482
482
|
'@cabloy/extend':
|
|
483
483
|
specifier: ^3.2.8
|
|
484
484
|
version: 3.2.8
|
|
@@ -564,7 +564,7 @@ importers:
|
|
|
564
564
|
specifier: ^1.1.17
|
|
565
565
|
version: link:../../packages-utils/zova-openapi
|
|
566
566
|
zova-vite:
|
|
567
|
-
specifier: ^1.1.
|
|
567
|
+
specifier: ^1.1.47
|
|
568
568
|
version: link:../../packages-utils/zova-vite
|
|
569
569
|
devDependencies:
|
|
570
570
|
clean-package:
|
|
@@ -1204,7 +1204,7 @@ importers:
|
|
|
1204
1204
|
dependencies:
|
|
1205
1205
|
axios:
|
|
1206
1206
|
specifier: ^1.18.1
|
|
1207
|
-
version: 1.20.0
|
|
1207
|
+
version: 1.20.0(supports-color@10.2.2)
|
|
1208
1208
|
devDependencies:
|
|
1209
1209
|
clean-package:
|
|
1210
1210
|
specifier: ^2.2.0
|
|
@@ -1477,7 +1477,7 @@ importers:
|
|
|
1477
1477
|
devDependencies:
|
|
1478
1478
|
'@cabloy/cli':
|
|
1479
1479
|
specifier: ^3.1.29
|
|
1480
|
-
version: 3.1.29(vue@3.5.42(typescript@5.9.3))
|
|
1480
|
+
version: 3.1.29(supports-color@10.2.2)(vue@3.5.42(typescript@5.9.3))
|
|
1481
1481
|
'@types/luxon':
|
|
1482
1482
|
specifier: ^3.7.1
|
|
1483
1483
|
version: 3.7.5
|
|
@@ -8851,7 +8851,7 @@ snapshots:
|
|
|
8851
8851
|
|
|
8852
8852
|
'@bufbuild/protobuf@2.15.0': {}
|
|
8853
8853
|
|
|
8854
|
-
'@cabloy/cli@3.1.29(vue@3.5.42(typescript@5.9.3))':
|
|
8854
|
+
'@cabloy/cli@3.1.29(supports-color@10.2.2)(vue@3.5.42(typescript@5.9.3))':
|
|
8855
8855
|
dependencies:
|
|
8856
8856
|
'@babel/parser': 7.29.8
|
|
8857
8857
|
'@cabloy/module-glob': 5.3.18
|
|
@@ -8860,7 +8860,7 @@ snapshots:
|
|
|
8860
8860
|
'@cabloy/utils': 2.1.27
|
|
8861
8861
|
'@cabloy/word-utils': link:packages-utils/word-utils
|
|
8862
8862
|
'@npmcli/config': 10.12.0
|
|
8863
|
-
'@zhennann/common-bin': 4.0.1
|
|
8863
|
+
'@zhennann/common-bin': 4.0.1(supports-color@10.2.2)
|
|
8864
8864
|
'@zhennann/ejs': 3.0.1
|
|
8865
8865
|
boxen: 4.2.0
|
|
8866
8866
|
chalk: 3.0.0
|
|
@@ -11048,7 +11048,7 @@ snapshots:
|
|
|
11048
11048
|
|
|
11049
11049
|
'@vue/shared@3.5.13': {}
|
|
11050
11050
|
|
|
11051
|
-
'@zhennann/common-bin@4.0.1':
|
|
11051
|
+
'@zhennann/common-bin@4.0.1(supports-color@10.2.2)':
|
|
11052
11052
|
dependencies:
|
|
11053
11053
|
chalk: 4.1.2
|
|
11054
11054
|
change-case: 4.1.2
|
|
@@ -11082,7 +11082,7 @@ snapshots:
|
|
|
11082
11082
|
|
|
11083
11083
|
acorn@8.18.0: {}
|
|
11084
11084
|
|
|
11085
|
-
agent-base@6.0.2:
|
|
11085
|
+
agent-base@6.0.2(supports-color@10.2.2):
|
|
11086
11086
|
dependencies:
|
|
11087
11087
|
debug: 4.4.3(supports-color@10.2.2)
|
|
11088
11088
|
transitivePeerDependencies:
|
|
@@ -11190,11 +11190,11 @@ snapshots:
|
|
|
11190
11190
|
dependencies:
|
|
11191
11191
|
possible-typed-array-names: 1.1.0
|
|
11192
11192
|
|
|
11193
|
-
axios@1.20.0:
|
|
11193
|
+
axios@1.20.0(supports-color@10.2.2):
|
|
11194
11194
|
dependencies:
|
|
11195
11195
|
follow-redirects: 1.16.0
|
|
11196
11196
|
form-data: 4.0.6
|
|
11197
|
-
https-proxy-agent: 5.0.1
|
|
11197
|
+
https-proxy-agent: 5.0.1(supports-color@10.2.2)
|
|
11198
11198
|
proxy-from-env: 2.1.0
|
|
11199
11199
|
transitivePeerDependencies:
|
|
11200
11200
|
- debug
|
|
@@ -12589,9 +12589,9 @@ snapshots:
|
|
|
12589
12589
|
statuses: 2.0.2
|
|
12590
12590
|
toidentifier: 1.0.1
|
|
12591
12591
|
|
|
12592
|
-
https-proxy-agent@5.0.1:
|
|
12592
|
+
https-proxy-agent@5.0.1(supports-color@10.2.2):
|
|
12593
12593
|
dependencies:
|
|
12594
|
-
agent-base: 6.0.2
|
|
12594
|
+
agent-base: 6.0.2(supports-color@10.2.2)
|
|
12595
12595
|
debug: 4.4.3(supports-color@10.2.2)
|
|
12596
12596
|
transitivePeerDependencies:
|
|
12597
12597
|
- supports-color
|