tina4-nodejs 3.13.142 → 3.13.143
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/CLAUDE.md +28 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +379 -85
- package/packages/cli/src/commands/generate.ts +7 -2
- package/packages/cli/src/commands/init.ts +18 -8
- package/packages/core/dist/index.js +377 -78
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +27 -11
- package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +20 -7
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +34 -15
- package/packages/core/src/csp.ts +138 -0
- package/packages/core/src/errorOverlay.ts +80 -37
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/middleware.ts +20 -6
- package/packages/core/src/response.ts +14 -3
- package/packages/core/src/server.ts +52 -15
- package/packages/core/src/session.ts +154 -7
- package/packages/core/src/sso.ts +1 -1
- package/packages/core/src/types.ts +8 -0
- package/packages/core/templates/errors/302.twig +1 -1
- package/packages/core/templates/errors/401.twig +2 -2
- package/packages/core/templates/errors/403.twig +1 -1
- package/packages/core/templates/errors/404.twig +1 -1
- package/packages/core/templates/errors/500.twig +1 -1
- package/packages/core/templates/errors/502.twig +2 -2
- package/packages/core/templates/errors/503.twig +2 -2
- package/packages/core/templates/errors/base.twig +4 -1
- package/packages/frond/dist/index.js +2 -0
- package/packages/frond/src/engine.ts +23 -0
- package/packages/orm/dist/index.js +378 -87
- package/packages/swagger/dist/index.js +4 -4
- package/packages/swagger/src/ui.ts +7 -4
- package/types/core/src/csp.d.ts +42 -0
- package/types/core/src/index.d.ts +1 -0
- package/types/core/src/middleware.d.ts +6 -0
- package/types/core/src/session.d.ts +62 -2
- package/types/core/src/types.d.ts +8 -0
- package/types/frond/src/engine.d.ts +7 -0
|
@@ -13,6 +13,7 @@ import { Log } from "./logger.js";
|
|
|
13
13
|
import { isTruthy } from "./dotenv.js";
|
|
14
14
|
import { defaultRouter, type Router } from "./router.js";
|
|
15
15
|
import { resolveClientIp } from "./trustedProxy.js";
|
|
16
|
+
import { currentCspNonce, resolveCspHeader } from "./csp.js";
|
|
16
17
|
import { getFrond, getFrameworkFrond, wantsJson, negotiatedErrorBody } from "./response.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -951,12 +952,18 @@ export class SecurityHeadersMiddleware {
|
|
|
951
952
|
* overrides, WITHOUT Strict-Transport-Security, which depends on the
|
|
952
953
|
* request's scheme. Also what a transport rejection carries (ADR-0068
|
|
953
954
|
* section 4), written before the scheme is known.
|
|
955
|
+
*
|
|
956
|
+
* Content-Security-Policy always carries a per-response nonce in style-src
|
|
957
|
+
* AND script-src (ADR-0088), read from the request async-context (minted on
|
|
958
|
+
* first access), so the framework's own inline content runs under the strict
|
|
959
|
+
* default without 'unsafe-inline'. The nonce is random per response, so a
|
|
960
|
+
* caller comparing this header must assert its STRUCTURE, never a byte string.
|
|
954
961
|
*/
|
|
955
962
|
static canonicalHeaders(): Record<string, string> {
|
|
956
963
|
return {
|
|
957
964
|
"X-Frame-Options": process.env.TINA4_FRAME_OPTIONS ?? "SAMEORIGIN",
|
|
958
965
|
"X-Content-Type-Options": "nosniff",
|
|
959
|
-
"Content-Security-Policy":
|
|
966
|
+
"Content-Security-Policy": resolveCspHeader(currentCspNonce()),
|
|
960
967
|
"Referrer-Policy": process.env.TINA4_REFERRER_POLICY ?? "strict-origin-when-cross-origin",
|
|
961
968
|
"X-XSS-Protection": "0",
|
|
962
969
|
"Permissions-Policy": process.env.TINA4_PERMISSIONS_POLICY ?? "camera=(), microphone=(), geolocation=()",
|
|
@@ -985,6 +992,10 @@ export class SecurityHeadersMiddleware {
|
|
|
985
992
|
if (process.env.TINA4_CSP === undefined) {
|
|
986
993
|
SecurityHeadersMiddleware.warnCspDefaultOnce();
|
|
987
994
|
}
|
|
995
|
+
// Content-Security-Policy carries a per-response nonce in style-src AND
|
|
996
|
+
// script-src (ADR-0088), already resolved in canonical, so the framework's
|
|
997
|
+
// own inline <style>/<script> (and user templates using the Frond
|
|
998
|
+
// csp_nonce() global) run under the strict policy without 'unsafe-inline'.
|
|
988
999
|
for (const name of ["Content-Security-Policy", "Referrer-Policy", "X-XSS-Protection", "Permissions-Policy"]) {
|
|
989
1000
|
res.header(name, canonical[name]);
|
|
990
1001
|
}
|
|
@@ -1012,11 +1023,14 @@ export class SecurityHeadersMiddleware {
|
|
|
1012
1023
|
SecurityHeadersMiddleware.cspDefaultWarned = true;
|
|
1013
1024
|
const message =
|
|
1014
1025
|
"TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy " +
|
|
1015
|
-
"\"default-src 'self'\" on every response.
|
|
1016
|
-
"
|
|
1017
|
-
"
|
|
1018
|
-
"
|
|
1019
|
-
"
|
|
1026
|
+
"\"default-src 'self'\" on every response. The framework injects a per-response " +
|
|
1027
|
+
"nonce into style-src and script-src, so its own inline <style>/<script> (and " +
|
|
1028
|
+
"your templates using the csp_nonce() Frond global) work under this policy. The " +
|
|
1029
|
+
"default still blocks cross-origin fonts/scripts/CDNs, data: URIs, and " +
|
|
1030
|
+
"cross-origin WebSocket/XHR (e.g. a separate API or LiveKit host). If your app " +
|
|
1031
|
+
"uses any of these, set TINA4_CSP to a policy that allows them (see " +
|
|
1032
|
+
"https://tina4.com); to silence this notice without changing behaviour, set " +
|
|
1033
|
+
"TINA4_CSP=\"default-src 'self'\".";
|
|
1020
1034
|
try {
|
|
1021
1035
|
Log.warning(message);
|
|
1022
1036
|
} catch {
|
|
@@ -11,6 +11,7 @@ import fs from "node:fs";
|
|
|
11
11
|
import nodePath from "node:path";
|
|
12
12
|
import { once } from "node:events";
|
|
13
13
|
import type { Tina4Response, CookieOptions } from "./types.js";
|
|
14
|
+
import { currentCspNonce } from "./csp.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Best-effort close of a streaming source on client disconnect or a mid-stream
|
|
@@ -93,7 +94,8 @@ export async function getFrond(): Promise<InstanceType<any>> {
|
|
|
93
94
|
const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
|
|
94
95
|
let engine = _frondCache.get(dir);
|
|
95
96
|
if (!engine) {
|
|
96
|
-
const { Frond } = await import("../../frond/src/engine.js");
|
|
97
|
+
const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
|
|
98
|
+
setCspNonceProvider(currentCspNonce);
|
|
97
99
|
engine = new Frond(dir);
|
|
98
100
|
_frondCache.set(dir, engine);
|
|
99
101
|
}
|
|
@@ -109,7 +111,8 @@ export async function getFrameworkFrond(): Promise<InstanceType<any> | null> {
|
|
|
109
111
|
const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates");
|
|
110
112
|
if (!_frameworkFrond && fs.existsSync(frameworkDir)) {
|
|
111
113
|
try {
|
|
112
|
-
const { Frond } = await import("../../frond/src/engine.js");
|
|
114
|
+
const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
|
|
115
|
+
setCspNonceProvider(currentCspNonce);
|
|
113
116
|
_frameworkFrond = new Frond(frameworkDir);
|
|
114
117
|
} catch { return null; }
|
|
115
118
|
}
|
|
@@ -262,6 +265,13 @@ export function createResponse(res: ServerResponse): Tina4Response {
|
|
|
262
265
|
// ── Attach the underlying ServerResponse ──
|
|
263
266
|
response.raw = res;
|
|
264
267
|
|
|
268
|
+
// ── Per-response CSP nonce (ADR-0088) ──
|
|
269
|
+
// Read the request-scoped nonce (csp.ts), minting one on first access. Inside
|
|
270
|
+
// a request this is the SAME value runDispatch() established and the security
|
|
271
|
+
// middleware names in the Content-Security-Policy header, so a route emitting
|
|
272
|
+
// an inline block as `<script nonce="${res.cspNonce}">` stays CSP-clean.
|
|
273
|
+
response.cspNonce = currentCspNonce();
|
|
274
|
+
|
|
265
275
|
// ── Explicit methods ──
|
|
266
276
|
|
|
267
277
|
response.json = function (data: unknown, status?: number): Tina4Response {
|
|
@@ -450,7 +460,8 @@ export function createResponse(res: ServerResponse): Tina4Response {
|
|
|
450
460
|
templateDir?: string,
|
|
451
461
|
): Promise<Tina4Response> {
|
|
452
462
|
try {
|
|
453
|
-
const { Frond } = await import("../../frond/src/engine.js");
|
|
463
|
+
const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
|
|
464
|
+
setCspNonceProvider(currentCspNonce);
|
|
454
465
|
const dir = templateDir ?? _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
|
|
455
466
|
let engine = _frondCache.get(dir);
|
|
456
467
|
if (!engine) {
|
|
@@ -42,6 +42,7 @@ import { MiddlewareChain, MiddlewareRunner, cors, requestLogger, isMiddlewareCla
|
|
|
42
42
|
import { tryServeStatic } from "./static.js";
|
|
43
43
|
import { loadEnv, isTruthy } from "./dotenv.js";
|
|
44
44
|
import { isDebugMode } from "./errorOverlay.js";
|
|
45
|
+
import { generateNonce, runWithCspNonce, currentCspNonce } from "./csp.js";
|
|
45
46
|
import { createHealthRoutes } from "./health.js";
|
|
46
47
|
import { rateLimiter } from "./rateLimiter.js";
|
|
47
48
|
import { Log } from "./logger.js";
|
|
@@ -493,7 +494,8 @@ async function renderErrorPage(
|
|
|
493
494
|
templatesDir: string,
|
|
494
495
|
): Promise<string | null> {
|
|
495
496
|
try {
|
|
496
|
-
const { Frond } = await import("../../frond/src/engine.js");
|
|
497
|
+
const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
|
|
498
|
+
setCspNonceProvider(currentCspNonce);
|
|
497
499
|
const templateFile = `errors/${code}.twig`;
|
|
498
500
|
|
|
499
501
|
// Helper: get-or-create a cached Frond instance for a directory
|
|
@@ -706,6 +708,10 @@ export function resolveTemplate(pathname: string, templatesDir: string): string
|
|
|
706
708
|
|
|
707
709
|
function renderLandingPage(port: number = 7148): string {
|
|
708
710
|
const version = TINA4_VERSION;
|
|
711
|
+
// The framework's welcome page runs under the strict default CSP. Every inline
|
|
712
|
+
// <style>/<script> below carries this response's nonce (ADR-0088); there are no
|
|
713
|
+
// style= or onclick= attributes (a nonce covers an element, never an attribute).
|
|
714
|
+
const nonce = currentCspNonce();
|
|
709
715
|
|
|
710
716
|
const galleryItems = [
|
|
711
717
|
{ id: "rest-api", icon: "🚀", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", accent: "accent-blue", tryUrl: "/api/gallery/hello" },
|
|
@@ -729,7 +735,7 @@ function renderLandingPage(port: number = 7148): string {
|
|
|
729
735
|
: "";
|
|
730
736
|
const deployBtn = isDeployed
|
|
731
737
|
? `<span class="gbtn gbtn-deployed">Deployed</span>`
|
|
732
|
-
: `<button class="gbtn gbtn-deploy"
|
|
738
|
+
: `<button class="gbtn gbtn-deploy" data-deploy="${item.id}">Deploy</button>`;
|
|
733
739
|
return `<div class="gallery-card">
|
|
734
740
|
<div class="accent ${item.accent}"></div>
|
|
735
741
|
<div class="icon">${item.icon}</div>
|
|
@@ -745,7 +751,7 @@ function renderLandingPage(port: number = 7148): string {
|
|
|
745
751
|
<meta charset="utf-8">
|
|
746
752
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
747
753
|
<title>Tina4NodeJs</title>
|
|
748
|
-
<style>
|
|
754
|
+
<style nonce="${nonce}">
|
|
749
755
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
750
756
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
|
|
751
757
|
.bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
|
|
@@ -784,6 +790,11 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
|
|
|
784
790
|
.gbtn-deployed{background:transparent;border:1px solid #22c55e;color:#22c55e;cursor:default;font-size:0.7rem}
|
|
785
791
|
@keyframes wiggle{0%{transform:rotate(0deg)}15%{transform:rotate(14deg)}30%{transform:rotate(-10deg)}45%{transform:rotate(8deg)}60%{transform:rotate(-4deg)}75%{transform:rotate(2deg)}100%{transform:rotate(0deg)}}
|
|
786
792
|
.star-wiggle{display:inline-block;transform-origin:center}
|
|
793
|
+
.gallery-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem}
|
|
794
|
+
.tok-comment{color:#64748b}
|
|
795
|
+
.tok-kw{color:#c084fc}
|
|
796
|
+
.tok-str{color:#4ade80}
|
|
797
|
+
.tok-fn{color:#38bdf8}
|
|
787
798
|
</style>
|
|
788
799
|
</head>
|
|
789
800
|
<body>
|
|
@@ -809,23 +820,23 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
|
|
|
809
820
|
<div class="section">
|
|
810
821
|
<div class="card">
|
|
811
822
|
<h2>Getting Started</h2>
|
|
812
|
-
<pre class="code-block"><code><span
|
|
813
|
-
<span
|
|
823
|
+
<pre class="code-block"><code><span class="tok-comment">// app.ts</span>
|
|
824
|
+
<span class="tok-kw">import</span> { startServer, Router } <span class="tok-kw">from</span> <span class="tok-str">"tina4-nodejs"</span>;
|
|
814
825
|
|
|
815
|
-
Router.get(<span
|
|
816
|
-
<span
|
|
826
|
+
Router.get(<span class="tok-str">"/hello"</span>, <span class="tok-kw">async</span> (<span class="tok-fn">req</span>, <span class="tok-fn">res</span>) => {
|
|
827
|
+
<span class="tok-kw">return</span> res.json({ message: <span class="tok-str">"Hello World!"</span> });
|
|
817
828
|
});
|
|
818
829
|
|
|
819
|
-
startServer({ port: 7148 }); <span
|
|
830
|
+
startServer({ port: 7148 }); <span class="tok-comment">// starts on port 7148</span></code></pre>
|
|
820
831
|
</div>
|
|
821
832
|
</div>
|
|
822
833
|
<div class="gallery">
|
|
823
834
|
<h2 id="gallery">What You Can Build</h2>
|
|
824
|
-
<div
|
|
835
|
+
<div class="gallery-cards">
|
|
825
836
|
${galleryCards}
|
|
826
837
|
</div>
|
|
827
838
|
</div>
|
|
828
|
-
<script>
|
|
839
|
+
<script nonce="${nonce}">
|
|
829
840
|
function deployGallery(name) {
|
|
830
841
|
if (!confirm('Deploy the "' + name + '" gallery example into your project? This will copy files into src/.')) return;
|
|
831
842
|
fetch('/__dev/api/gallery/deploy', {
|
|
@@ -860,6 +871,12 @@ function deployGallery(name) {
|
|
|
860
871
|
}
|
|
861
872
|
setTimeout(doWiggle,3000);
|
|
862
873
|
})();
|
|
874
|
+
// CSP-clean wiring: no inline onclick handlers — bind every Deploy button from
|
|
875
|
+
// its data-deploy attribute (a nonce covers script elements, not event-handler
|
|
876
|
+
// attributes).
|
|
877
|
+
document.querySelectorAll('.gbtn-deploy[data-deploy]').forEach(function(btn){
|
|
878
|
+
btn.addEventListener('click', function(){ deployGallery(btn.dataset.deploy); });
|
|
879
|
+
});
|
|
863
880
|
</script>
|
|
864
881
|
</body>
|
|
865
882
|
</html>`;
|
|
@@ -1685,7 +1702,8 @@ export async function buildDispatchContext(router: Router, base?: string): Promi
|
|
|
1685
1702
|
|
|
1686
1703
|
let frondEngine: DispatchContext["frondEngine"] = null;
|
|
1687
1704
|
try {
|
|
1688
|
-
const { Frond } = await import("../../frond/src/engine.js");
|
|
1705
|
+
const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
|
|
1706
|
+
setCspNonceProvider(currentCspNonce);
|
|
1689
1707
|
frondEngine = new Frond(templatesDir);
|
|
1690
1708
|
} catch {
|
|
1691
1709
|
// Frond not available — template-route fallback stays inert, same guard startServer() uses.
|
|
@@ -1891,7 +1909,14 @@ export async function runDispatch(
|
|
|
1891
1909
|
// requests interleaving on the one event loop never read each other's id.
|
|
1892
1910
|
const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes(4).toString("hex");
|
|
1893
1911
|
if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
|
|
1894
|
-
|
|
1912
|
+
// One CSP nonce per request (ADR-0088), established in the SAME async scope as
|
|
1913
|
+
// the request id so the inline HTML body, the Frond csp_nonce() global, and the
|
|
1914
|
+
// security middleware's Content-Security-Policy header all name one value - and
|
|
1915
|
+
// two requests interleaving on the one event loop never share it.
|
|
1916
|
+
const requestNonce = generateNonce();
|
|
1917
|
+
return Log.runWithRequestId(requestId, () =>
|
|
1918
|
+
runWithCspNonce(requestNonce, () => dispatchInner(ctx, rawReq, rawRes, requestId)),
|
|
1919
|
+
);
|
|
1895
1920
|
}
|
|
1896
1921
|
|
|
1897
1922
|
/**
|
|
@@ -2128,7 +2153,8 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
2128
2153
|
let frondEngine: any = null;
|
|
2129
2154
|
setDefaultTemplatesDir(templatesDir);
|
|
2130
2155
|
try {
|
|
2131
|
-
const { Frond } = await import("../../frond/src/engine.js");
|
|
2156
|
+
const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
|
|
2157
|
+
setCspNonceProvider(currentCspNonce);
|
|
2132
2158
|
frondEngine = new Frond(templatesDir);
|
|
2133
2159
|
|
|
2134
2160
|
// Always-on Frond {% live %} refresh endpoint. Re-renders a server-rendered
|
|
@@ -2367,10 +2393,21 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
2367
2393
|
});
|
|
2368
2394
|
|
|
2369
2395
|
aiServer.on("error", (err: any) => {
|
|
2370
|
-
|
|
2396
|
+
// The auxiliary AI/test port is a debug convenience, never
|
|
2397
|
+
// load-bearing: ANY bind failure must degrade to no-aux-port, logged
|
|
2398
|
+
// LOUD, never silent. The old handler logged only EADDRINUSE and
|
|
2399
|
+
// silently swallowed every other code (EADDRNOTAVAIL, EACCES, ...),
|
|
2400
|
+
// leaving `aiServer` a dangling non-listening object. The 'error'
|
|
2401
|
+
// listener's presence already stops Node throwing, so the main server
|
|
2402
|
+
// never crashes; this just makes the degrade visible and resets the
|
|
2403
|
+
// handle on every failure. (The synchronous ERR_SOCKET_BAD_PORT for
|
|
2404
|
+
// an out-of-range derived port is handled separately by aiPortInRange.)
|
|
2405
|
+
if (err?.code === "EADDRINUSE") {
|
|
2371
2406
|
Log.warning(`Test port ${testPort} in use — skipping`);
|
|
2372
|
-
|
|
2407
|
+
} else {
|
|
2408
|
+
Log.error(`Test port ${testPort} bind failed (${err?.code ?? err}) — skipping`);
|
|
2373
2409
|
}
|
|
2410
|
+
aiServer = null;
|
|
2374
2411
|
});
|
|
2375
2412
|
|
|
2376
2413
|
aiServer.listen(testPort, host);
|
|
@@ -335,6 +335,28 @@ export class RedisSessionHandler extends RespSessionHandler {
|
|
|
335
335
|
|
|
336
336
|
const FLASH_PREFIX = "_flash_";
|
|
337
337
|
|
|
338
|
+
/**
|
|
339
|
+
* The stored form of a value, for telling whether a request changed it.
|
|
340
|
+
* undefined when it cannot be serialised (a BigInt, a cycle): that always
|
|
341
|
+
* counts as changed, because writing a value again is harmless and missing a
|
|
342
|
+
* change is not.
|
|
343
|
+
*/
|
|
344
|
+
function fingerprint(value: unknown): string | undefined {
|
|
345
|
+
try {
|
|
346
|
+
return JSON.stringify(value);
|
|
347
|
+
} catch {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function fingerprints(data: SessionData): Map<string, string | undefined> {
|
|
353
|
+
const prints = new Map<string, string | undefined>();
|
|
354
|
+
for (const [key, value] of Object.entries(data)) {
|
|
355
|
+
prints.set(key, fingerprint(value));
|
|
356
|
+
}
|
|
357
|
+
return prints;
|
|
358
|
+
}
|
|
359
|
+
|
|
338
360
|
// ── Session Class ─────────────────────────────────────────────────
|
|
339
361
|
|
|
340
362
|
export class Session {
|
|
@@ -361,6 +383,29 @@ export class Session {
|
|
|
361
383
|
* Lets `start()` tell "no such session" from "the store is unreachable".
|
|
362
384
|
*/
|
|
363
385
|
private lastReadFailed = false;
|
|
386
|
+
/**
|
|
387
|
+
* What this request last saw of the stored record, one fingerprint per key:
|
|
388
|
+
* taken by start() and again by every save. save() compares the live data
|
|
389
|
+
* against it to find the keys THIS request changed.
|
|
390
|
+
*/
|
|
391
|
+
private loaded = new Map<string, string | undefined>();
|
|
392
|
+
/**
|
|
393
|
+
* True when this request is working on a record the store holds: start()
|
|
394
|
+
* adopted one, or a save has written one since. A save never re-creates such
|
|
395
|
+
* a record once another request has removed it.
|
|
396
|
+
*/
|
|
397
|
+
private stored = false;
|
|
398
|
+
/**
|
|
399
|
+
* True when clear() ran since the last save: the save then replaces the
|
|
400
|
+
* stored record instead of merging into it.
|
|
401
|
+
*/
|
|
402
|
+
private cleared = false;
|
|
403
|
+
/**
|
|
404
|
+
* True once this request found its session ended by another request (the
|
|
405
|
+
* record it loaded is gone). It stays ended for the rest of the request:
|
|
406
|
+
* regenerate() mints nothing either.
|
|
407
|
+
*/
|
|
408
|
+
private ended = false;
|
|
364
409
|
|
|
365
410
|
constructor(backend?: string, config?: SessionConfig) {
|
|
366
411
|
const backendType = resolveBackend(
|
|
@@ -529,6 +574,11 @@ export class Session {
|
|
|
529
574
|
const now = Math.floor(Date.now() / 1000);
|
|
530
575
|
this.data = { _created: now, _accessed: now };
|
|
531
576
|
this.dirty = false;
|
|
577
|
+
// Not a record this request has seen, so its first save writes whole.
|
|
578
|
+
this.stored = false;
|
|
579
|
+
this.loaded = new Map();
|
|
580
|
+
this.cleared = false;
|
|
581
|
+
this.ended = false;
|
|
532
582
|
return sessionId;
|
|
533
583
|
}
|
|
534
584
|
if (loaded) {
|
|
@@ -552,6 +602,10 @@ export class Session {
|
|
|
552
602
|
// Refresh the accessed timestamp; a write failure here is logged but
|
|
553
603
|
// must not abort the resume — the request still serves.
|
|
554
604
|
this.safeWrite(this.sessionId, this.data, this.ttl);
|
|
605
|
+
this.stored = true;
|
|
606
|
+
this.loaded = fingerprints(this.data);
|
|
607
|
+
this.cleared = false;
|
|
608
|
+
this.ended = false;
|
|
555
609
|
return sessionId;
|
|
556
610
|
}
|
|
557
611
|
}
|
|
@@ -562,6 +616,10 @@ export class Session {
|
|
|
562
616
|
this.data = { _created: now, _accessed: now };
|
|
563
617
|
this.dirty = false;
|
|
564
618
|
this.safeWrite(this.sessionId, this.data, this.ttl);
|
|
619
|
+
this.stored = false;
|
|
620
|
+
this.loaded = new Map();
|
|
621
|
+
this.cleared = false;
|
|
622
|
+
this.ended = false;
|
|
565
623
|
return this.sessionId;
|
|
566
624
|
}
|
|
567
625
|
|
|
@@ -606,9 +664,7 @@ export class Session {
|
|
|
606
664
|
if (this.sessionId) {
|
|
607
665
|
this.safeDestroy(this.sessionId);
|
|
608
666
|
}
|
|
609
|
-
this.
|
|
610
|
-
this.data = null;
|
|
611
|
-
this.dirty = false;
|
|
667
|
+
this.forget();
|
|
612
668
|
}
|
|
613
669
|
|
|
614
670
|
/**
|
|
@@ -633,6 +689,7 @@ export class Session {
|
|
|
633
689
|
const now = Math.floor(Date.now() / 1000);
|
|
634
690
|
this.data = { _created: this.data._created, _accessed: now };
|
|
635
691
|
this.dirty = true;
|
|
692
|
+
this.cleared = true;
|
|
636
693
|
this.save();
|
|
637
694
|
}
|
|
638
695
|
|
|
@@ -651,8 +708,27 @@ export class Session {
|
|
|
651
708
|
* session fixation — the pre-auth ID is destroyed and the data is carried
|
|
652
709
|
* onto a fresh, unguessable ID. A backend destroy/write failure is logged
|
|
653
710
|
* (never silent) but does not throw under the default policy.
|
|
711
|
+
*
|
|
712
|
+
* What is carried is the session as it is stored now with this request's
|
|
713
|
+
* own changes applied, the same merge save() does. If another request ended
|
|
714
|
+
* the session after this one loaded it (a logout, or a regenerate of its
|
|
715
|
+
* own), it stays ended: nothing is carried, no id is minted and null is
|
|
716
|
+
* returned, so no cookie goes out to replace the one that request sent.
|
|
654
717
|
*/
|
|
655
|
-
regenerate(): string {
|
|
718
|
+
regenerate(): string | null {
|
|
719
|
+
if (this.ended) return null;
|
|
720
|
+
if (this.sessionId && this.stored && this.data) {
|
|
721
|
+
const current = this.safeRead(this.sessionId);
|
|
722
|
+
if (!this.lastReadFailed) {
|
|
723
|
+
if (!current) {
|
|
724
|
+
this.forget();
|
|
725
|
+
this.ended = true;
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
if (!this.cleared) this.data = this.merged(current);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
656
732
|
const oldId = this.sessionId;
|
|
657
733
|
const oldData = this.data;
|
|
658
734
|
|
|
@@ -666,6 +742,8 @@ export class Session {
|
|
|
666
742
|
this.data = oldData ?? { _created: Math.floor(Date.now() / 1000), _accessed: Math.floor(Date.now() / 1000) };
|
|
667
743
|
this.data._accessed = Math.floor(Date.now() / 1000);
|
|
668
744
|
this.dirty = true;
|
|
745
|
+
// The new id has no record yet: write everything to it.
|
|
746
|
+
this.stored = false;
|
|
669
747
|
this.save();
|
|
670
748
|
return this.sessionId;
|
|
671
749
|
}
|
|
@@ -749,17 +827,86 @@ export class Session {
|
|
|
749
827
|
*
|
|
750
828
|
* Returns true on a successful persist, false if the backend was unreachable
|
|
751
829
|
* (logged). The dirty flag is cleared only on success so a later save()
|
|
752
|
-
* retries once the backend recovers. A
|
|
830
|
+
* retries once the backend recovers. A call with no session returns true.
|
|
831
|
+
*
|
|
832
|
+
* Other requests on the same session run while this one does, so the save
|
|
833
|
+
* writes only THIS request's changes, onto the record as it is stored now.
|
|
834
|
+
* Writing back the whole snapshot start() loaded undid whatever another
|
|
835
|
+
* request did in between: a logout (destroy, clear, regenerate) or a
|
|
836
|
+
* privilege change made with set(). A record removed since this request
|
|
837
|
+
* loaded it is never re-created: the session has ended, for this request
|
|
838
|
+
* too. A request that has not loaded a stored record (a new session, the new
|
|
839
|
+
* id regenerate() mints) writes its data whole, as before.
|
|
840
|
+
*
|
|
841
|
+
* A session the store already holds is re-written even when this request
|
|
842
|
+
* changed nothing: that write re-stamps the backend deadline to
|
|
843
|
+
* now + TINA4_SESSION_TTL, so a session expires after that span of INACTIVITY
|
|
844
|
+
* rather than that long after its last CHANGE (ADR-0087 — a session's expiry
|
|
845
|
+
* slides on activity). The re-write is the re-read, merged record, so sliding
|
|
846
|
+
* the deadline is concurrent-safe: it never clobbers another request's change,
|
|
847
|
+
* never re-creates an ended record, and TINA4_SESSION_TTL=0 still re-stamps a
|
|
848
|
+
* never-expires deadline (ADR-0027). A request with no stored session and no
|
|
849
|
+
* change writes nothing — there is no deadline to move.
|
|
753
850
|
*/
|
|
754
851
|
save(): boolean {
|
|
755
852
|
if (!this.sessionId || !this.data) return true;
|
|
756
|
-
if (!this.dirty) return true;
|
|
757
|
-
|
|
853
|
+
if (!this.dirty && !this.stored) return true;
|
|
854
|
+
let record: SessionData = this.data;
|
|
855
|
+
if (this.stored) {
|
|
856
|
+
const current = this.safeRead(this.sessionId);
|
|
857
|
+
if (this.lastReadFailed) return false; // nothing written; dirty RETAINED for retry
|
|
858
|
+
if (!current) {
|
|
859
|
+
this.forget();
|
|
860
|
+
this.ended = true;
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
if (!this.cleared) record = this.merged(current);
|
|
864
|
+
}
|
|
865
|
+
if (this.safeWrite(this.sessionId, record, this.ttl)) {
|
|
758
866
|
this.dirty = false;
|
|
867
|
+
this.stored = true;
|
|
868
|
+
this.cleared = false;
|
|
869
|
+
this.loaded = fingerprints(this.data);
|
|
759
870
|
return true;
|
|
760
871
|
}
|
|
761
872
|
return false; // write failed (logged); dirty RETAINED for retry
|
|
762
873
|
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* The stored record with this request's changes applied: every key whose
|
|
877
|
+
* value differs from what this request loaded or last saved, and the
|
|
878
|
+
* removal of every key it deleted. Keys it did not touch keep whatever is
|
|
879
|
+
* stored now.
|
|
880
|
+
*/
|
|
881
|
+
private merged(current: SessionData): SessionData {
|
|
882
|
+
const data = this.data as SessionData;
|
|
883
|
+
const record: SessionData = { ...current };
|
|
884
|
+
for (const [key, value] of Object.entries(data)) {
|
|
885
|
+
const print = fingerprint(value);
|
|
886
|
+
if (print === undefined || !this.loaded.has(key) || this.loaded.get(key) !== print) {
|
|
887
|
+
record[key] = value;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
for (const key of this.loaded.keys()) {
|
|
891
|
+
if (!Object.prototype.hasOwnProperty.call(data, key)) {
|
|
892
|
+
delete record[key];
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return record;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* End the session for this request without touching the store: no id and
|
|
900
|
+
* no data, so nothing more is written and no cookie goes out for it.
|
|
901
|
+
*/
|
|
902
|
+
private forget(): void {
|
|
903
|
+
this.sessionId = null;
|
|
904
|
+
this.data = null;
|
|
905
|
+
this.dirty = false;
|
|
906
|
+
this.stored = false;
|
|
907
|
+
this.cleared = false;
|
|
908
|
+
this.loaded = new Map();
|
|
909
|
+
}
|
|
763
910
|
}
|
|
764
911
|
|
|
765
912
|
/**
|
package/packages/core/src/sso.ts
CHANGED
|
@@ -126,6 +126,14 @@ export interface Tina4ResponseMethods {
|
|
|
126
126
|
stream(source: AsyncIterable<string | Buffer>, contentType?: string): Promise<Tina4Response>;
|
|
127
127
|
/** The underlying ServerResponse for advanced use */
|
|
128
128
|
raw: ServerResponse;
|
|
129
|
+
/**
|
|
130
|
+
* This response's Content-Security-Policy nonce (ADR-0088). `runDispatch()`
|
|
131
|
+
* mints one per request and publishes it on the async-context (csp.ts) so the
|
|
132
|
+
* framework's inline `<style>`/`<script>`, the Frond `csp_nonce()` global, and
|
|
133
|
+
* the value the security middleware names in the header all agree. Stamp it on
|
|
134
|
+
* any inline block a route emits: `<script nonce="${res.cspNonce}">`.
|
|
135
|
+
*/
|
|
136
|
+
cspNonce: string;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
/**
|
|
@@ -17,6 +17,6 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
17
17
|
<div class="spinner"></div>
|
|
18
18
|
<div class="error-title">Redirecting…</div>
|
|
19
19
|
<div class="error-msg">You are being redirected to a new location.</div>
|
|
20
|
-
<div class="error-path
|
|
20
|
+
<div class="error-path c-primary">{{ redirect_url }}</div>
|
|
21
21
|
<a href="{{ redirect_url }}" class="error-home">Click here if not redirected</a>
|
|
22
22
|
{% endblock %}
|
|
@@ -9,9 +9,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
9
9
|
{% extends "errors/base.twig" %}
|
|
10
10
|
{% block title %}401 — Unauthorized{% endblock %}
|
|
11
11
|
{% block content %}
|
|
12
|
-
<div class="error-code
|
|
12
|
+
<div class="error-code c-danger">401</div>
|
|
13
13
|
<div class="error-title">Unauthorized</div>
|
|
14
14
|
<div class="error-msg">You need to sign in to access this resource.</div>
|
|
15
|
-
<div class="error-path
|
|
15
|
+
<div class="error-path c-danger">{{ path }}</div>
|
|
16
16
|
<a href="/" class="error-home">Go Home</a>
|
|
17
17
|
{% endblock %}
|
|
@@ -12,7 +12,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
12
12
|
<meta charset="utf-8">
|
|
13
13
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
14
|
<title>403 — Forbidden</title>
|
|
15
|
-
<style>
|
|
15
|
+
<style nonce="{{ csp_nonce() }}">
|
|
16
16
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
17
17
|
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
18
18
|
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
@@ -12,7 +12,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
12
12
|
<meta charset="utf-8">
|
|
13
13
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
14
|
<title>404 — Not Found</title>
|
|
15
|
-
<style>
|
|
15
|
+
<style nonce="{{ csp_nonce() }}">
|
|
16
16
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
17
17
|
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
18
18
|
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
@@ -12,7 +12,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
12
12
|
<meta charset="utf-8">
|
|
13
13
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
14
|
<title>500 — Server Error</title>
|
|
15
|
-
<style>
|
|
15
|
+
<style nonce="{{ csp_nonce() }}">
|
|
16
16
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
17
17
|
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
18
18
|
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: left; max-width: 700px; width: 90%; }
|
|
@@ -9,9 +9,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
9
9
|
{% extends "errors/base.twig" %}
|
|
10
10
|
{% block title %}502 — Bad Gateway{% endblock %}
|
|
11
11
|
{% block content %}
|
|
12
|
-
<div class="error-code
|
|
12
|
+
<div class="error-code c-danger">502</div>
|
|
13
13
|
<div class="error-title">Bad Gateway</div>
|
|
14
14
|
<div class="error-msg">The upstream server returned an invalid response.</div>
|
|
15
|
-
<div class="error-path
|
|
15
|
+
<div class="error-path c-danger">{{ path }}</div>
|
|
16
16
|
<a href="/" class="error-home">Go Home</a>
|
|
17
17
|
{% endblock %}
|
|
@@ -12,9 +12,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
12
12
|
.error-code { color: var(--warn); }
|
|
13
13
|
{% endblock %}
|
|
14
14
|
{% block content %}
|
|
15
|
-
<div class="error-code
|
|
15
|
+
<div class="error-code c-warn">503</div>
|
|
16
16
|
<div class="error-title">Service Unavailable</div>
|
|
17
17
|
<div class="error-msg">The server is temporarily unavailable. Please try again shortly.</div>
|
|
18
|
-
<div class="error-path
|
|
18
|
+
<div class="error-path c-warn">{{ path }}</div>
|
|
19
19
|
<a href="/" class="error-home">Go Home</a>
|
|
20
20
|
{% endblock %}
|
|
@@ -13,7 +13,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
13
13
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
14
14
|
{% block meta %}{% endblock %}
|
|
15
15
|
<title>{% block title %}Error{% endblock %}</title>
|
|
16
|
-
<style>
|
|
16
|
+
<style nonce="{{ csp_nonce() }}">
|
|
17
17
|
:root {
|
|
18
18
|
--bg: #0f172a; --surface: #1e293b; --border: #334155;
|
|
19
19
|
--text: #e2e8f0; --muted: #94a3b8; --primary: #3b82f6;
|
|
@@ -31,6 +31,9 @@ body { font-family: var(--font); background: var(--bg); color: var(--text); min-
|
|
|
31
31
|
.error-home { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.5rem; background: var(--primary); color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.85rem; }
|
|
32
32
|
.error-home:hover { opacity: 0.9; }
|
|
33
33
|
.logo { width: 3rem; height: 3rem; margin-bottom: 1rem; opacity: 0.5; }
|
|
34
|
+
.c-danger { color: var(--danger); }
|
|
35
|
+
.c-warn { color: var(--warn); }
|
|
36
|
+
.c-primary { color: var(--primary); }
|
|
34
37
|
{% block extra_styles %}{% endblock %}
|
|
35
38
|
</style>
|
|
36
39
|
</head>
|
|
@@ -1413,6 +1413,7 @@ function _b64url(data) {
|
|
|
1413
1413
|
return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1414
1414
|
}
|
|
1415
1415
|
var _formTokenSessionId = "";
|
|
1416
|
+
var _cspNonceProvider = () => "";
|
|
1416
1417
|
function _buildFormTokenJwt(descriptor = "") {
|
|
1417
1418
|
const secret = process.env.TINA4_SECRET ?? "";
|
|
1418
1419
|
const secretBytes = Buffer.byteLength(secret, "utf8");
|
|
@@ -1604,6 +1605,7 @@ var Frond = class _Frond {
|
|
|
1604
1605
|
this.globals.formTokenValue = (descriptor) => _generateFormTokenValue(descriptor || "");
|
|
1605
1606
|
this.globals.form_token_value = (descriptor) => _generateFormTokenValue(descriptor || "");
|
|
1606
1607
|
this.globals.dump = (value) => renderDump(value);
|
|
1608
|
+
this.globals.csp_nonce = () => _cspNonceProvider();
|
|
1607
1609
|
for (const [k, v] of _Frond.classFilters) this.filters[k] = v;
|
|
1608
1610
|
for (const [k, v] of _Frond.classGlobals) this.globals[k] = v;
|
|
1609
1611
|
for (const [k, v] of _Frond.classTests) this.tests[k] = v;
|