@things-factory/auth-base 10.1.40 → 10.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/controllers/reset-password.d.ts +6 -0
- package/dist-server/controllers/reset-password.js +12 -13
- package/dist-server/controllers/reset-password.js.map +1 -1
- package/dist-server/controllers/unlock-user.d.ts +6 -0
- package/dist-server/controllers/unlock-user.js +12 -13
- package/dist-server/controllers/unlock-user.js.map +1 -1
- package/dist-server/controllers/utils/find-verification-token.d.ts +16 -0
- package/dist-server/controllers/utils/find-verification-token.js +40 -0
- package/dist-server/controllers/utils/find-verification-token.js.map +1 -0
- package/dist-server/controllers/utils/save-verification-token.d.ts +8 -1
- package/dist-server/controllers/utils/save-verification-token.js +10 -2
- package/dist-server/controllers/utils/save-verification-token.js.map +1 -1
- package/dist-server/controllers/verification.d.ts +6 -0
- package/dist-server/controllers/verification.js +13 -8
- package/dist-server/controllers/verification.js.map +1 -1
- package/dist-server/middlewares/jwt-authenticate-middleware.js +3 -1
- package/dist-server/middlewares/jwt-authenticate-middleware.js.map +1 -1
- package/dist-server/router/auth-checkin-router.js +2 -1
- package/dist-server/router/auth-checkin-router.js.map +1 -1
- package/dist-server/router/auth-public-process-router.js +31 -8
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/role-template/index.d.ts +2 -1
- package/dist-server/service/role-template/index.js +2 -1
- package/dist-server/service/role-template/index.js.map +1 -1
- package/dist-server/service/role-template/role-template-gap-resolver.d.ts +10 -0
- package/dist-server/service/role-template/role-template-gap-resolver.js +41 -0
- package/dist-server/service/role-template/role-template-gap-resolver.js.map +1 -0
- package/dist-server/service/role-template/role-template-types.d.ts +10 -0
- package/dist-server/service/role-template/role-template-types.js +41 -1
- package/dist-server/service/role-template/role-template-types.js.map +1 -1
- package/dist-server/service/role-template/role-template.d.ts +25 -0
- package/dist-server/service/role-template/role-template.js +32 -0
- package/dist-server/service/role-template/role-template.js.map +1 -1
- package/dist-server/service/verification-token/verification-token.d.ts +5 -0
- package/dist-server/service/verification-token/verification-token.js +6 -1
- package/dist-server/service/verification-token/verification-token.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/same-origin-redirect.d.ts +4 -0
- package/dist-server/utils/same-origin-redirect.js +39 -0
- package/dist-server/utils/same-origin-redirect.js.map +1 -0
- package/dist-server/utils/verification-token-rule.d.ts +16 -0
- package/dist-server/utils/verification-token-rule.js +73 -0
- package/dist-server/utils/verification-token-rule.js.map +1 -0
- package/package.json +4 -4
- package/tests/invitation-route.test.ts +36 -17
- package/tests/lock-recovery.test.ts +3 -1
- package/tests/open-fields-framework.test.ts +212 -0
- package/tests/open-redirect-db.test.ts +143 -0
- package/tests/role-template-gap-db.test.ts +125 -0
- package/tests/verification-token-db.test.ts +277 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sameOriginRedirectPath = sameOriginRedirectPath;
|
|
4
|
+
/**
|
|
5
|
+
* Where a sign-in or check-in may send the browser afterwards.
|
|
6
|
+
*
|
|
7
|
+
* `redirect_to` arrives in the query string, so anyone can write a link that passes through our
|
|
8
|
+
* login page and lands on a host of their choosing — the address bar shows our host until the
|
|
9
|
+
* last hop. Before this, `/auth/checkin?redirect_to=https://elsewhere` did exactly that: in
|
|
10
|
+
* fixed-domain mode `getRedirectSubdomainPath` hands the value back untouched, and otherwise
|
|
11
|
+
* `new URL(value, base)` keeps the value's own host.
|
|
12
|
+
*
|
|
13
|
+
* The answer here is always a path on this site. A value that resolves to another origin becomes
|
|
14
|
+
* `'/'` — the same place a missing value goes. An absolute URL on our own host is kept as its
|
|
15
|
+
* path, since the 401 redirect and older links may carry one.
|
|
16
|
+
*
|
|
17
|
+
* Leading slashes are collapsed to one: `/.//elsewhere` resolves to the path `//elsewhere`, which
|
|
18
|
+
* a browser reads as a host.
|
|
19
|
+
*/
|
|
20
|
+
const PROBE_ORIGIN = 'http://same-origin.invalid';
|
|
21
|
+
function sameOriginRedirectPath(redirectTo, context) {
|
|
22
|
+
if (typeof redirectTo !== 'string' || !redirectTo.trim()) {
|
|
23
|
+
return '/';
|
|
24
|
+
}
|
|
25
|
+
let url;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(redirectTo, `${PROBE_ORIGIN}/`);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return '/';
|
|
31
|
+
}
|
|
32
|
+
const ownHosts = [context.host, context.headers?.['x-forwarded-host']].filter(Boolean);
|
|
33
|
+
const sameOrigin = url.origin === PROBE_ORIGIN || ((url.protocol === 'http:' || url.protocol === 'https:') && ownHosts.includes(url.host));
|
|
34
|
+
if (!sameOrigin) {
|
|
35
|
+
return '/';
|
|
36
|
+
}
|
|
37
|
+
return `${url.pathname.replace(/^\/+/, '/')}${url.search}${url.hash}`;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=same-origin-redirect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"same-origin-redirect.js","sourceRoot":"","sources":["../../server/utils/same-origin-redirect.ts"],"names":[],"mappings":";;AAkBA,wDAqBC;AAvCD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,YAAY,GAAG,4BAA4B,CAAA;AAEjD,SAAgB,sBAAsB,CAAC,UAAmB,EAAE,OAAyC;IACnG,IAAI,OAAO,UAAU,KAAK,QAAQ,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC;QACzD,OAAO,GAAG,CAAA;IACZ,CAAC;IAED,IAAI,GAAQ,CAAA;IACZ,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,UAAU,EAAE,GAAG,YAAY,GAAG,CAAC,CAAA;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,CAAA;IACZ,CAAC;IAED,MAAM,QAAQ,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,kBAAkB,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtF,MAAM,UAAU,GACd,GAAG,CAAC,MAAM,KAAK,YAAY,IAAI,CAAC,CAAC,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAA;IAEzH,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,GAAG,CAAA;IACZ,CAAC;IAED,OAAO,GAAG,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,CAAA;AACvE,CAAC","sourcesContent":["/**\n * Where a sign-in or check-in may send the browser afterwards.\n *\n * `redirect_to` arrives in the query string, so anyone can write a link that passes through our\n * login page and lands on a host of their choosing — the address bar shows our host until the\n * last hop. Before this, `/auth/checkin?redirect_to=https://elsewhere` did exactly that: in\n * fixed-domain mode `getRedirectSubdomainPath` hands the value back untouched, and otherwise\n * `new URL(value, base)` keeps the value's own host.\n *\n * The answer here is always a path on this site. A value that resolves to another origin becomes\n * `'/'` — the same place a missing value goes. An absolute URL on our own host is kept as its\n * path, since the 401 redirect and older links may carry one.\n *\n * Leading slashes are collapsed to one: `/.//elsewhere` resolves to the path `//elsewhere`, which\n * a browser reads as a host.\n */\nconst PROBE_ORIGIN = 'http://same-origin.invalid'\n\nexport function sameOriginRedirectPath(redirectTo: unknown, context: { host?: string; headers?: any }): string {\n if (typeof redirectTo !== 'string' || !redirectTo.trim()) {\n return '/'\n }\n\n let url: URL\n try {\n url = new URL(redirectTo, `${PROBE_ORIGIN}/`)\n } catch {\n return '/'\n }\n\n const ownHosts = [context.host, context.headers?.['x-forwarded-host']].filter(Boolean)\n const sameOrigin =\n url.origin === PROBE_ORIGIN || ((url.protocol === 'http:' || url.protocol === 'https:') && ownHosts.includes(url.host))\n\n if (!sameOrigin) {\n return '/'\n }\n\n return `${url.pathname.replace(/^\\/+/, '/')}${url.search}${url.hash}`\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const VERIFICATION_TOKEN_LIFETIME_MS: Readonly<Record<string, number>>;
|
|
2
|
+
/** The value the table keeps for a token. */
|
|
3
|
+
export declare function hashVerificationToken(token: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* When a token of this type, issued at `now`, stops working.
|
|
6
|
+
*
|
|
7
|
+
* Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives
|
|
8
|
+
* rather than inherit forever.
|
|
9
|
+
*/
|
|
10
|
+
export declare function verificationTokenExpiresAt(type: string, now?: Date): Date;
|
|
11
|
+
/** The parts of a stored token this rule reads. */
|
|
12
|
+
export type VerificationTokenRecord = {
|
|
13
|
+
expiresAt?: Date | string | null;
|
|
14
|
+
} | null | undefined;
|
|
15
|
+
/** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */
|
|
16
|
+
export declare function isVerificationTokenLive(record: VerificationTokenRecord, now?: Date): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VERIFICATION_TOKEN_LIFETIME_MS = void 0;
|
|
4
|
+
exports.hashVerificationToken = hashVerificationToken;
|
|
5
|
+
exports.verificationTokenExpiresAt = verificationTokenExpiresAt;
|
|
6
|
+
exports.isVerificationTokenLive = isVerificationTokenLive;
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
8
|
+
/**
|
|
9
|
+
* How long a mailed link lives, and what the table keeps of it. (Pure rule.)
|
|
10
|
+
*
|
|
11
|
+
* Password reset, unlock and sign-up confirmation each mail a link that carries a token, and the
|
|
12
|
+
* token is the whole credential: whoever holds the link resets the password or unlocks the account.
|
|
13
|
+
* Before 2026-09-24 those tokens never expired, were stored as written and were found by that same
|
|
14
|
+
* value — so a reset mail from last year still changed a password today, and a copy of the table
|
|
15
|
+
* (a backup, a replica, a dump handed to support) was a set of live reset links.
|
|
16
|
+
*
|
|
17
|
+
* ── The lifetimes ──────────────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* password-reset 1 hour hands the account over at once, so short
|
|
20
|
+
* unlock 1 hour the same
|
|
21
|
+
* activation 48 hours the person may read the mail late; it only confirms an address
|
|
22
|
+
*
|
|
23
|
+
* Decided with the architect, 2026-09-24 (`docs/design/auth-rebuild-questions-2026-09-24.md` Q4).
|
|
24
|
+
*
|
|
25
|
+
* ── Expiry is its own column ───────────────────────────────────────────────
|
|
26
|
+
* Not derived from `createdAt` or `updatedAt`: `updatedAt` moves every time the row is saved again,
|
|
27
|
+
* and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,
|
|
28
|
+
* with the token.
|
|
29
|
+
*
|
|
30
|
+
* A row without `expiresAt` reads as **expired** — the rule `invitation-state.ts` uses. Such rows
|
|
31
|
+
* were written before this existed, with the token in plain text; refusing them is also how links
|
|
32
|
+
* already sent stop working when this ships.
|
|
33
|
+
*
|
|
34
|
+
* ── What is stored is a hash ───────────────────────────────────────────────
|
|
35
|
+
* sha256 of the token. The token is 16 random bytes, so there is nothing for a slow hash to protect
|
|
36
|
+
* — it guards against guessing, and there is no guessing a random 128-bit value. The point is only
|
|
37
|
+
* that the table no longer holds anything a link can be made from.
|
|
38
|
+
*/
|
|
39
|
+
const HOUR = 60 * 60 * 1000;
|
|
40
|
+
exports.VERIFICATION_TOKEN_LIFETIME_MS = Object.freeze({
|
|
41
|
+
'password-reset': 1 * HOUR,
|
|
42
|
+
unlock: 1 * HOUR,
|
|
43
|
+
activation: 48 * HOUR
|
|
44
|
+
});
|
|
45
|
+
/** The value the table keeps for a token. */
|
|
46
|
+
function hashVerificationToken(token) {
|
|
47
|
+
return (0, crypto_1.createHash)('sha256').update(token, 'utf8').digest('hex');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* When a token of this type, issued at `now`, stops working.
|
|
51
|
+
*
|
|
52
|
+
* Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives
|
|
53
|
+
* rather than inherit forever.
|
|
54
|
+
*/
|
|
55
|
+
function verificationTokenExpiresAt(type, now = new Date()) {
|
|
56
|
+
const lifetime = exports.VERIFICATION_TOKEN_LIFETIME_MS[type];
|
|
57
|
+
if (!lifetime) {
|
|
58
|
+
throw new Error(`verification token type '${type}' has no lifetime`);
|
|
59
|
+
}
|
|
60
|
+
return new Date(now.getTime() + lifetime);
|
|
61
|
+
}
|
|
62
|
+
/** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */
|
|
63
|
+
function isVerificationTokenLive(record, now = new Date()) {
|
|
64
|
+
if (!record?.expiresAt) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const at = record.expiresAt instanceof Date ? record.expiresAt : new Date(record.expiresAt);
|
|
68
|
+
if (Number.isNaN(at.getTime())) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return now.getTime() < at.getTime();
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=verification-token-rule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verification-token-rule.js","sourceRoot":"","sources":["../../server/utils/verification-token-rule.ts"],"names":[],"mappings":";;;AA2CA,sDAEC;AAQD,gEAQC;AAMD,0DAYC;AA/ED,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAEd,QAAA,8BAA8B,GAAqC,MAAM,CAAC,MAAM,CAAC;IAC5F,gBAAgB,EAAE,CAAC,GAAG,IAAI;IAC1B,MAAM,EAAE,CAAC,GAAG,IAAI;IAChB,UAAU,EAAE,EAAE,GAAG,IAAI;CACtB,CAAC,CAAA;AAEF,6CAA6C;AAC7C,SAAgB,qBAAqB,CAAC,KAAa;IACjD,OAAO,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACjE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,0BAA0B,CAAC,IAAY,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7E,MAAM,QAAQ,GAAG,sCAA8B,CAAC,IAAI,CAAC,CAAA;IAErD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,mBAAmB,CAAC,CAAA;IACtE,CAAC;IAED,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,QAAQ,CAAC,CAAA;AAC3C,CAAC;AAKD,0GAA0G;AAC1G,SAAgB,uBAAuB,CAAC,MAA+B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7F,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;QACvB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IAE3F,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAA;AACrC,CAAC","sourcesContent":["import { createHash } from 'crypto'\n\n/**\n * How long a mailed link lives, and what the table keeps of it. (Pure rule.)\n *\n * Password reset, unlock and sign-up confirmation each mail a link that carries a token, and the\n * token is the whole credential: whoever holds the link resets the password or unlocks the account.\n * Before 2026-09-24 those tokens never expired, were stored as written and were found by that same\n * value — so a reset mail from last year still changed a password today, and a copy of the table\n * (a backup, a replica, a dump handed to support) was a set of live reset links.\n *\n * ── The lifetimes ──────────────────────────────────────────────────────────\n *\n * password-reset 1 hour hands the account over at once, so short\n * unlock 1 hour the same\n * activation 48 hours the person may read the mail late; it only confirms an address\n *\n * Decided with the architect, 2026-09-24 (`docs/design/auth-rebuild-questions-2026-09-24.md` Q4).\n *\n * ── Expiry is its own column ───────────────────────────────────────────────\n * Not derived from `createdAt` or `updatedAt`: `updatedAt` moves every time the row is saved again,\n * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,\n * with the token.\n *\n * A row without `expiresAt` reads as **expired** — the rule `invitation-state.ts` uses. Such rows\n * were written before this existed, with the token in plain text; refusing them is also how links\n * already sent stop working when this ships.\n *\n * ── What is stored is a hash ───────────────────────────────────────────────\n * sha256 of the token. The token is 16 random bytes, so there is nothing for a slow hash to protect\n * — it guards against guessing, and there is no guessing a random 128-bit value. The point is only\n * that the table no longer holds anything a link can be made from.\n */\n\nconst HOUR = 60 * 60 * 1000\n\nexport const VERIFICATION_TOKEN_LIFETIME_MS: Readonly<Record<string, number>> = Object.freeze({\n 'password-reset': 1 * HOUR,\n unlock: 1 * HOUR,\n activation: 48 * HOUR\n})\n\n/** The value the table keeps for a token. */\nexport function hashVerificationToken(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/**\n * When a token of this type, issued at `now`, stops working.\n *\n * Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives\n * rather than inherit forever.\n */\nexport function verificationTokenExpiresAt(type: string, now: Date = new Date()): Date {\n const lifetime = VERIFICATION_TOKEN_LIFETIME_MS[type]\n\n if (!lifetime) {\n throw new Error(`verification token type '${type}' has no lifetime`)\n }\n\n return new Date(now.getTime() + lifetime)\n}\n\n/** The parts of a stored token this rule reads. */\nexport type VerificationTokenRecord = { expiresAt?: Date | string | null } | null | undefined\n\n/** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */\nexport function isVerificationTokenLive(record: VerificationTokenRecord, now: Date = new Date()): boolean {\n if (!record?.expiresAt) {\n return false\n }\n\n const at = record.expiresAt instanceof Date ? record.expiresAt : new Date(record.expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return false\n }\n\n return now.getTime() < at.getTime()\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.46",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"@reduxjs/toolkit": "^2.2.5",
|
|
35
35
|
"@simplewebauthn/browser": "^13.0.0",
|
|
36
36
|
"@simplewebauthn/server": "^13.0.0",
|
|
37
|
-
"@things-factory/email-base": "^10.1.
|
|
37
|
+
"@things-factory/email-base": "^10.1.46",
|
|
38
38
|
"@things-factory/env": "^10.1.20",
|
|
39
|
-
"@things-factory/shell": "^10.1.
|
|
39
|
+
"@things-factory/shell": "^10.1.46",
|
|
40
40
|
"@things-factory/utils": "^10.1.20",
|
|
41
41
|
"@types/webappsec-credential-management": "^0.6.9",
|
|
42
42
|
"jsonwebtoken": "^9.0.0",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"passport-jwt": "^4.0.0",
|
|
49
49
|
"passport-local": "^1.0.0"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "d3467cde550453f3348fed0ed6c79a12a6ba0db4"
|
|
52
52
|
}
|
|
@@ -71,27 +71,46 @@ describe('the invitation link', () => {
|
|
|
71
71
|
|
|
72
72
|
describe('the other links sent by mail', () => {
|
|
73
73
|
/*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
74
|
+
* Reset, unlock and sign-up confirmation moved to the invitation's shape on 2026-09-24 (Q4): one
|
|
75
|
+
* exported path, the token after `#`. Each page answers GET where the mail points and POST where
|
|
76
|
+
* the page sends the token.
|
|
77
77
|
*/
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
[...readFileSync(join(CONTROLLERS, file), 'utf-8').matchAll(/new URL\(`(\/auth\/[^`?$]*)/g)].map(m => ({
|
|
84
|
-
file,
|
|
85
|
-
path: m[1].replace(/\/$/, '/:token')
|
|
86
|
-
}))
|
|
87
|
-
)
|
|
78
|
+
const { RESET_PASSWORD_PATH } = loadCompiled('controllers/reset-password')
|
|
79
|
+
const { UNLOCK_USER_PATH } = loadCompiled('controllers/unlock-user')
|
|
80
|
+
const { VERIFY_EMAIL_PATH } = loadCompiled('controllers/verification')
|
|
81
|
+
|
|
82
|
+
const paths = [RESET_PASSWORD_PATH, UNLOCK_USER_PATH, VERIFY_EMAIL_PATH]
|
|
88
83
|
|
|
89
|
-
it('
|
|
90
|
-
|
|
91
|
-
expect(linked.map(l => l.file).sort()).toEqual(['reset-password.ts', 'unlock-user.ts', 'verification.ts'])
|
|
84
|
+
it('names the three it means to check', () => {
|
|
85
|
+
expect(paths).toEqual(['/auth/reset-password', '/auth/unlock-user', '/auth/verify'])
|
|
92
86
|
})
|
|
93
87
|
|
|
94
|
-
it.each(
|
|
88
|
+
it.each(paths)('%s — the page answers where the mail points', path => {
|
|
95
89
|
expect(answers('GET', path)).toBe(true)
|
|
96
90
|
})
|
|
91
|
+
|
|
92
|
+
it.each(paths)('%s — and listens where the page posts', path => {
|
|
93
|
+
expect(answers('POST', path)).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('no longer answers the old link that confirmed on opening', () => {
|
|
97
|
+
/* `GET /auth/verify/<token>` confirmed the address for whoever fetched it — a mail scanner too. */
|
|
98
|
+
expect(answers('GET', '/auth/verify/0123456789abcdef0123456789abcdef')).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('no controller puts a token in a query string or a path', () => {
|
|
102
|
+
const CONTROLLERS = join(__dirname, '..', 'server', 'controllers')
|
|
103
|
+
const offending = readdirSync(CONTROLLERS)
|
|
104
|
+
.filter(file => file.endsWith('.ts'))
|
|
105
|
+
.filter(file => /\?token=\$\{|\/verify\/\$\{/.test(readFileSync(join(CONTROLLERS, file), 'utf-8')))
|
|
106
|
+
|
|
107
|
+
expect(offending).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('nor does the redirect for a password that must be reset', () => {
|
|
111
|
+
const middleware = readFileSync(join(__dirname, '..', 'server', 'middlewares', 'jwt-authenticate-middleware.ts'), 'utf-8')
|
|
112
|
+
|
|
113
|
+
expect(middleware).toContain('context.redirect(`${RESET_PASSWORD_PATH}#${token}`)')
|
|
114
|
+
expect(middleware).not.toMatch(/\?token=/)
|
|
115
|
+
})
|
|
97
116
|
})
|
|
@@ -20,6 +20,7 @@ const { signin } = loadCompiled('controllers/signin')
|
|
|
20
20
|
const { resetPassword } = loadCompiled('controllers/reset-password')
|
|
21
21
|
const { unlockUser } = loadCompiled('controllers/unlock-user')
|
|
22
22
|
const { VerificationToken, VerificationTokenType } = loadCompiled('service/verification-token/verification-token')
|
|
23
|
+
const { saveVerificationToken } = loadCompiled('controllers/utils/save-verification-token')
|
|
23
24
|
|
|
24
25
|
const OLD = 'Passw0rd!'
|
|
25
26
|
const NEW = 'N3wPassw0rd!'
|
|
@@ -41,8 +42,9 @@ async function lockedUser() {
|
|
|
41
42
|
})
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/* Through the one writer, so the row carries its hash and expiry the way a mailed link's does. */
|
|
44
46
|
async function giveToken(userId: string, token: string, type: string) {
|
|
45
|
-
await
|
|
47
|
+
await saveVerificationToken(userId, token, type)
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/** `resetPassword` 가 컨텍스트에서 읽는 것만 담는다. */
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { buildSchema, getMetadataStorage } from 'type-graphql'
|
|
2
|
+
import { createPubSub } from '@graphql-yoga/subscription'
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
|
|
6
|
+
import { loadCompiled } from './compiled'
|
|
7
|
+
|
|
8
|
+
const { privilegeDirectiveResolver } = loadCompiled('service/privilege/privilege-directive')
|
|
9
|
+
const companion = loadCompiled('service/index').schema.resolverClasses
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every things-factory module with resolvers says which of its fields are open (§8 step 1).
|
|
13
|
+
*
|
|
14
|
+
* ── Two modes ───────────────────────────────────────────────────────────────
|
|
15
|
+
* Normally this is the check. For each module that declares `schema.openFields`, the directive
|
|
16
|
+
* must accept it in boot mode — no field is open without being listed — and no name on its lists
|
|
17
|
+
* may be stale (a field that has since been given @privilege, or that no longer exists). A list
|
|
18
|
+
* that keeps names after they are closed stops telling anyone what is left.
|
|
19
|
+
*
|
|
20
|
+
* With `GENERATE_OPEN_FIELDS=<package>[,<package>…]` it writes that module's declaration from
|
|
21
|
+
* what its schema serves today: every undeclared root field goes under `notYetDeclared`, and the
|
|
22
|
+
* module's `server/service/index.ts` gains the one key. Behaviour does not change — everything
|
|
23
|
+
* that was open is listed. Sorting a field into `byDesign` with its reason, or closing it, is the
|
|
24
|
+
* work of step 2 and is done by hand.
|
|
25
|
+
*
|
|
26
|
+
* ── How a module is measured ────────────────────────────────────────────────
|
|
27
|
+
* The way the server builds it: the module's own `resolverClasses`, `buildSchema` with a pub/sub
|
|
28
|
+
* (modules with subscriptions do not build without one), and the privilege directive's record of
|
|
29
|
+
* what each field declares. A module with mutations and no query cannot build alone, so every
|
|
30
|
+
* build carries auth-base's resolvers beside it, and only the module's own fields — by the class
|
|
31
|
+
* that declared them, from type-graphql's metadata — are counted.
|
|
32
|
+
*
|
|
33
|
+
* ⚠ Reads `dist-server`. After editing a module, build it before this means anything.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const PACKAGES = join(__dirname, '..', '..')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The modules that have declared their lists. Named rather than counted, the way `screen-words` names
|
|
40
|
+
* the packages that owe words: a module that opts in is added here in the same commit, and the day
|
|
41
|
+
* every module with resolvers is on it, the "not yet" half of this file goes.
|
|
42
|
+
*
|
|
43
|
+
* 2026-09-24: auth-base only. The generator below writes the rest; running it for the framework is
|
|
44
|
+
* the next step of §8 step 1 and was held back from the 10.1.38 cut so it could be verified first.
|
|
45
|
+
*/
|
|
46
|
+
const OPTED_IN = ['auth-base']
|
|
47
|
+
|
|
48
|
+
/** Modules that cannot be measured here, and why. Adding one needs its reason. */
|
|
49
|
+
const CANNOT_MEASURE: Record<string, string> = {
|
|
50
|
+
calendar:
|
|
51
|
+
"its compiled entry throws on load when required on its own (`events` of `Attendee` has no inferable type). None of the four products loads it."
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Measured = { name: string; own: string[]; undeclared: string[]; schema: any }
|
|
55
|
+
|
|
56
|
+
function modulesWithResolvers(): string[] {
|
|
57
|
+
return readdirSync(PACKAGES)
|
|
58
|
+
.filter(name => existsSync(join(PACKAGES, name, 'dist-server', 'service', 'index.js')))
|
|
59
|
+
.filter(name => {
|
|
60
|
+
const source = join(PACKAGES, name, 'server', 'service', 'index.ts')
|
|
61
|
+
return existsSync(source) && readFileSync(source, 'utf-8').includes('resolverClasses')
|
|
62
|
+
})
|
|
63
|
+
.sort()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function measure(name: string): Promise<Measured> {
|
|
67
|
+
const schema = require(join(PACKAGES, name, 'dist-server', 'service', 'index.js')).schema
|
|
68
|
+
const classes = new Set<Function>(schema?.resolverClasses || [])
|
|
69
|
+
|
|
70
|
+
const storage = getMetadataStorage() as any
|
|
71
|
+
const own: string[] = []
|
|
72
|
+
for (const [typeName, bucket] of [
|
|
73
|
+
['Query', 'queries'],
|
|
74
|
+
['Mutation', 'mutations'],
|
|
75
|
+
['Subscription', 'subscriptions']
|
|
76
|
+
]) {
|
|
77
|
+
for (const field of storage[bucket] || []) {
|
|
78
|
+
if (classes.has(field.target)) own.push(`${typeName}.${field.schemaName}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
process['PRIVILEGE_OF_FIELD'] = {}
|
|
83
|
+
const built = await buildSchema({
|
|
84
|
+
resolvers: (name === 'auth-base' ? [...classes] : [...companion, ...classes]) as any,
|
|
85
|
+
validate: false,
|
|
86
|
+
pubSub: createPubSub() as any
|
|
87
|
+
})
|
|
88
|
+
privilegeDirectiveResolver(built, { modules: [] })
|
|
89
|
+
|
|
90
|
+
const record = process['PRIVILEGE_OF_FIELD'] as Record<string, unknown>
|
|
91
|
+
const undeclared = own.filter(where => record[where] === null).sort()
|
|
92
|
+
|
|
93
|
+
return { name, own: own.sort(), undeclared, schema }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function declarationSource(name: string, undeclared: string[]): string {
|
|
97
|
+
return `/**
|
|
98
|
+
* ${name}'s root fields that carry no \`@privilege\` (auth-base \`docs/design/auth-rebuild.md\` §8).
|
|
99
|
+
*
|
|
100
|
+
* Written from what this module served on the day it opted in, so nothing that was open stopped
|
|
101
|
+
* working. Every name starts under \`notYetDeclared\`: nobody has yet decided what each one guards.
|
|
102
|
+
* Closing one means giving it \`@privilege(category: …, privilege: …)\` and taking it off this list.
|
|
103
|
+
* A field that is open on purpose moves to \`byDesign\`, with its reason.
|
|
104
|
+
*
|
|
105
|
+
* A new root field that is on neither list, and has no \`@privilege\`, stops the development server
|
|
106
|
+
* and is refused in production — see \`privilege/open-field-rule.ts\` in auth-base.
|
|
107
|
+
*/
|
|
108
|
+
export const openFields = {
|
|
109
|
+
byDesign: {} as Record<string, string>,
|
|
110
|
+
notYetDeclared: [
|
|
111
|
+
${undeclared.map(where => ` '${where}'`).join(',\n')}${undeclared.length ? '\n' : ''} ]
|
|
112
|
+
}
|
|
113
|
+
`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function optIn(name: string, undeclared: string[]) {
|
|
117
|
+
const dir = join(PACKAGES, name, 'server', 'service')
|
|
118
|
+
writeFileSync(join(dir, 'open-fields.ts'), declarationSource(name, undeclared))
|
|
119
|
+
|
|
120
|
+
const indexPath = join(dir, 'index.ts')
|
|
121
|
+
let index = readFileSync(indexPath, 'utf-8')
|
|
122
|
+
|
|
123
|
+
if (!index.includes("from './open-fields.js'")) {
|
|
124
|
+
/* After the last import line, so the module's own import order is left as it was. */
|
|
125
|
+
const lines = index.split('\n')
|
|
126
|
+
let last = -1
|
|
127
|
+
lines.forEach((line, i) => {
|
|
128
|
+
if (/^import .* from '.*'$/.test(line) || /^} from '.*'$/.test(line)) last = i
|
|
129
|
+
})
|
|
130
|
+
lines.splice(last + 1, 0, "import { openFields } from './open-fields.js'")
|
|
131
|
+
index = lines.join('\n')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!/export const schema = \{\n\s*openFields,/.test(index)) {
|
|
135
|
+
index = index.replace('export const schema = {', 'export const schema = {\n openFields,')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
writeFileSync(indexPath, index)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const GENERATE = (process.env.GENERATE_OPEN_FIELDS || '').split(',').map(s => s.trim()).filter(Boolean)
|
|
142
|
+
|
|
143
|
+
if (GENERATE.length) {
|
|
144
|
+
it(`writes the declarations for ${GENERATE.join(', ')}`, async () => {
|
|
145
|
+
for (const name of GENERATE) {
|
|
146
|
+
const measured = await measure(name)
|
|
147
|
+
optIn(name, measured.undeclared)
|
|
148
|
+
}
|
|
149
|
+
}, 600000)
|
|
150
|
+
} else {
|
|
151
|
+
const names = modulesWithResolvers().filter(name => !(name in CANNOT_MEASURE))
|
|
152
|
+
|
|
153
|
+
describe('every module with resolvers', () => {
|
|
154
|
+
it('finds the modules it means to check', () => {
|
|
155
|
+
/* A walk that finds nothing passes whatever the modules say. */
|
|
156
|
+
expect(names.length).toBeGreaterThan(50)
|
|
157
|
+
expect(names).toContain('auth-base')
|
|
158
|
+
expect(names).toContain('shell')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('the modules that have opted in are exactly the ones named', async () => {
|
|
162
|
+
/*
|
|
163
|
+
* ⚠ Both directions. A module that declares lists without being named here is caught, and so is
|
|
164
|
+
* one named here that has lost its lists — either way the name list stops saying what is true.
|
|
165
|
+
*/
|
|
166
|
+
const optedIn: string[] = []
|
|
167
|
+
for (const name of names) {
|
|
168
|
+
const schema = require(join(PACKAGES, name, 'dist-server', 'service', 'index.js')).schema
|
|
169
|
+
if (schema?.openFields) optedIn.push(name)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
expect(optedIn.sort()).toEqual([...OPTED_IN].sort())
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it.each(OPTED_IN)('%s says which of its fields are open', async name => {
|
|
176
|
+
const measured = await measure(name)
|
|
177
|
+
|
|
178
|
+
expect(measured.schema?.openFields).toBeTruthy()
|
|
179
|
+
|
|
180
|
+
/* The directive in boot mode is the check the development server runs. */
|
|
181
|
+
process['PRIVILEGE_OF_FIELD'] = {}
|
|
182
|
+
const built = await buildSchema({
|
|
183
|
+
resolvers: (name === 'auth-base'
|
|
184
|
+
? measured.schema.resolverClasses
|
|
185
|
+
: [...companion, ...measured.schema.resolverClasses]) as any,
|
|
186
|
+
validate: false,
|
|
187
|
+
pubSub: createPubSub() as any
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
expect(() =>
|
|
191
|
+
privilegeDirectiveResolver(built, {
|
|
192
|
+
modules: [{ name, schema: measured.schema }],
|
|
193
|
+
mode: 'boot'
|
|
194
|
+
})
|
|
195
|
+
).not.toThrow()
|
|
196
|
+
|
|
197
|
+
/* No stale names: every listed field is one this module serves and has not yet closed. */
|
|
198
|
+
const declaration = measured.schema.openFields
|
|
199
|
+
const listed = [...Object.keys(declaration.byDesign || {}), ...(declaration.notYetDeclared || [])]
|
|
200
|
+
const stale = listed.filter(where => !measured.undeclared.includes(where))
|
|
201
|
+
|
|
202
|
+
expect(stale).toEqual([])
|
|
203
|
+
}, 120000)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('what cannot be measured here', () => {
|
|
207
|
+
it.each(Object.entries(CANNOT_MEASURE))('%s — %s', name => {
|
|
208
|
+
/* Still listed as a module with resolvers — the day it can be measured, this fails and it moves. */
|
|
209
|
+
expect(modulesWithResolvers()).toContain(name)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
2
|
+
import { loadCompiled } from './compiled'
|
|
3
|
+
import { sameOriginRedirectPath } from '../server/utils/same-origin-redirect'
|
|
4
|
+
|
|
5
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
6
|
+
const { Role } = loadCompiled('service/role/role')
|
|
7
|
+
const { User, UserStatus } = loadCompiled('service/user/user')
|
|
8
|
+
const { authCheckinRouter } = loadCompiled('router/auth-checkin-router')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `/auth/checkin?redirect_to=` must not send the browser to another host.
|
|
12
|
+
*
|
|
13
|
+
* Every sign-in ends there: the 401 redirect, the sign-in form and the WebAuthn sign-in all hand
|
|
14
|
+
* their `redirect_to` to the check-in page, and the check-in page is where `context.redirect` runs.
|
|
15
|
+
* So a link like `https://our-host/auth/checkin?redirect_to=https://elsewhere` used to show our
|
|
16
|
+
* login page and then land on `elsewhere` — the open redirect this file closes.
|
|
17
|
+
*
|
|
18
|
+
* The first half drives the real route with the value an attacker would write, because a test of
|
|
19
|
+
* the helper alone would stay green if the route stopped calling it. The second half pins the
|
|
20
|
+
* helper's reading of the shapes a browser treats as "another host".
|
|
21
|
+
*
|
|
22
|
+
* ⚠ Fixed-domain mode (`config.subdomain`) is read once when shell loads, so this suite runs the
|
|
23
|
+
* path-domain branch only. The helper runs before either branch, so both see the same value.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const OUR_HOST = 'ex.test'
|
|
27
|
+
|
|
28
|
+
let acme: any
|
|
29
|
+
let person: any
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
await openAuthDatabase()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await closeAuthDatabase()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
await resetAuthDatabase()
|
|
41
|
+
|
|
42
|
+
acme = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
43
|
+
const seeing = await getRepository(Privilege).save({ name: 'query', category: 'board' })
|
|
44
|
+
const role = await getRepository(Role).save({ name: 'viewer', domain: { id: acme.id }, privileges: [seeing] })
|
|
45
|
+
|
|
46
|
+
const saved = await getRepository(User).save({
|
|
47
|
+
username: 'kim@acme.z',
|
|
48
|
+
email: 'kim@acme.z',
|
|
49
|
+
name: 'kim',
|
|
50
|
+
status: UserStatus.ACTIVATED,
|
|
51
|
+
domains: [{ id: acme.id }],
|
|
52
|
+
roles: [role]
|
|
53
|
+
})
|
|
54
|
+
person = await getRepository(User).findOne({ where: { id: saved.id }, relations: ['domains', 'roles'] })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/** Runs `GET /auth/checkin` the way a browser following a link would, and returns where it sent it. */
|
|
58
|
+
async function checkinRedirectsTo(redirectTo: string): Promise<string> {
|
|
59
|
+
const layer = authCheckinRouter.stack.find((layer: any) => layer.path === '/auth/checkin/:subdomain?')
|
|
60
|
+
const redirect = jest.fn()
|
|
61
|
+
const render = jest.fn()
|
|
62
|
+
|
|
63
|
+
const context: any = {
|
|
64
|
+
method: 'GET',
|
|
65
|
+
host: OUR_HOST,
|
|
66
|
+
href: `http://${OUR_HOST}/auth/checkin`,
|
|
67
|
+
header: {},
|
|
68
|
+
headers: {},
|
|
69
|
+
request: { header: { accept: 'text/html' } },
|
|
70
|
+
req: { headers: {}, connection: { remoteAddress: '127.0.0.1' } },
|
|
71
|
+
t: (key: string) => key,
|
|
72
|
+
state: { user: person },
|
|
73
|
+
params: {},
|
|
74
|
+
query: { redirect_to: redirectTo },
|
|
75
|
+
redirect,
|
|
76
|
+
render
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await layer.stack[layer.stack.length - 1](context, async () => {})
|
|
80
|
+
|
|
81
|
+
expect(render).not.toHaveBeenCalled()
|
|
82
|
+
expect(redirect).toHaveBeenCalledTimes(1)
|
|
83
|
+
return redirect.mock.calls[0][0]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('GET /auth/checkin with a single domain', () => {
|
|
87
|
+
it('still goes where it was asked, on this host', async () => {
|
|
88
|
+
const target = new URL(await checkinRedirectsTo('/domain/acme/board-list?x=1'))
|
|
89
|
+
|
|
90
|
+
expect(target.host).toBe(OUR_HOST)
|
|
91
|
+
expect(target.pathname + target.search).toBe('/domain/acme/board-list?x=1')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it.each([
|
|
95
|
+
'https://evil.example/steal',
|
|
96
|
+
'//evil.example/steal',
|
|
97
|
+
'/\\evil.example/steal',
|
|
98
|
+
'https:evil.example',
|
|
99
|
+
'\t//evil.example'
|
|
100
|
+
])('does not leave this host for %j', async redirectTo => {
|
|
101
|
+
const target = new URL(await checkinRedirectsTo(redirectTo))
|
|
102
|
+
|
|
103
|
+
expect(target.host).toBe(OUR_HOST)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('sameOriginRedirectPath', () => {
|
|
108
|
+
const context = { host: OUR_HOST, headers: {} }
|
|
109
|
+
|
|
110
|
+
it.each([
|
|
111
|
+
['/domain/acme/x?y=1#z', '/domain/acme/x?y=1#z'],
|
|
112
|
+
[`http://${OUR_HOST}/domain/acme/x`, '/domain/acme/x'],
|
|
113
|
+
['board-list', '/board-list']
|
|
114
|
+
])('keeps %j on this site as %j', (value, path) => {
|
|
115
|
+
expect(sameOriginRedirectPath(value, context)).toBe(path)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('keeps an absolute URL on the host the proxy says we are', () => {
|
|
119
|
+
expect(sameOriginRedirectPath('https://public.example/x', { host: 'internal:3000', headers: { 'x-forwarded-host': 'public.example' } })).toBe('/x')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it.each([
|
|
123
|
+
'https://evil.example/x',
|
|
124
|
+
'//evil.example/x',
|
|
125
|
+
'/\\evil.example/x',
|
|
126
|
+
'https:evil.example/x',
|
|
127
|
+
'\t//evil.example/x',
|
|
128
|
+
'/\t/evil.example/x',
|
|
129
|
+
'javascript:alert(1)',
|
|
130
|
+
`ftp://${OUR_HOST}/x`
|
|
131
|
+
])('sends %j home', value => {
|
|
132
|
+
expect(sameOriginRedirectPath(value, context)).toBe('/')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('does not return a path a browser reads as a host', () => {
|
|
136
|
+
/* `/.//evil.example` resolves on our origin to the path `//evil.example`. */
|
|
137
|
+
expect(sameOriginRedirectPath('/.//evil.example', context)).toBe('/evil.example')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it.each([undefined, '', ' ', ['/a', '/b']])('sends %j home', value => {
|
|
141
|
+
expect(sameOriginRedirectPath(value, context)).toBe('/')
|
|
142
|
+
})
|
|
143
|
+
})
|