jskelet 0.1.1 → 0.1.2
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/AGENTS.md +5 -0
- package/CHANGELOG.md +63 -0
- package/README.md +21 -7
- package/bin/jskelet.mjs +6 -6
- package/docs/03-routing.md +48 -9
- package/docs/04-render-ve-sablonlar.md +2 -2
- package/docs/05-islands.md +59 -6
- package/docs/06-cache.md +39 -7
- package/docs/07-yapilandirma.md +51 -1
- package/docs/08-build.md +4 -4
- package/docs/09-dev-araclari.md +5 -0
- package/docs/12-panel-ve-oturum.md +384 -0
- package/docs/README.md +25 -2
- package/docs/en/01-getting-started.md +292 -0
- package/docs/en/02-architecture.md +305 -0
- package/docs/en/03-routing.md +493 -0
- package/docs/en/04-rendering.md +504 -0
- package/docs/en/05-islands.md +492 -0
- package/docs/en/06-caching.md +454 -0
- package/docs/en/07-configuration.md +736 -0
- package/docs/en/08-build.md +383 -0
- package/docs/en/09-dev-tools.md +314 -0
- package/docs/en/10-deployment.md +332 -0
- package/docs/en/11-migration.md +360 -0
- package/docs/en/12-dashboards-and-sessions.md +392 -0
- package/docs/en/README.md +112 -0
- package/package.json +4 -2
- package/src/build/build.mjs +1 -1
- package/src/build/tasks/client.mjs +2 -2
- package/src/build/tasks/fonts.mjs +3 -3
- package/src/build/tasks/icons.mjs +1 -1
- package/src/build/tasks/images.mjs +2 -2
- package/src/client/devtools/overlay.js +196 -164
- package/src/client/devtools/report.js +96 -96
- package/src/client/form.js +192 -0
- package/src/client/index.js +10 -1
- package/src/client/registry.js +78 -4
- package/src/client/swap.js +188 -0
- package/src/config/defaults.js +34 -0
- package/src/config/index.js +68 -13
- package/src/config/pattern.js +1 -1
- package/src/dev-server.mjs +1 -1
- package/src/http/control-flow.js +16 -1
- package/src/http/cookies.js +257 -0
- package/src/http/request-context.js +162 -0
- package/src/index.js +19 -2
- package/src/init.mjs +32 -31
- package/src/log.mjs +8 -2
- package/src/logo.png +0 -0
- package/src/runtime/alias-hooks.mjs +1 -1
- package/src/server/assets.js +1 -1
- package/src/server/create-app.js +12 -4
- package/src/server/dev/devtools.js +6 -2
- package/src/server/dev/version-check.mjs +139 -0
- package/src/server/head-hints.js +1 -1
- package/src/server/html-cache.js +10 -4
- package/src/server/middleware/csrf.js +134 -0
- package/src/server/prewarm.js +6 -6
- package/src/server/render.js +199 -16
- package/src/server/router.js +14 -7
- package/src/server/status-page.js +1 -1
- package/src/version.mjs +9 -4
- package/src/views/components/loader.js +1 -1
- package/src/views/helpers/tags.js +53 -1
package/src/config/index.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* redirects() → [{ source, destination, permanent?, statusCode? }]
|
|
17
17
|
* rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
|
|
18
18
|
* cache() → { html?: { [source]: saniye }, prewarm?: {...} }
|
|
19
|
+
*
|
|
20
|
+
* Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
|
|
21
|
+
* düz nesne olarak okunur.
|
|
19
22
|
*/
|
|
20
23
|
import fs from "node:fs";
|
|
21
24
|
import path from "node:path";
|
|
@@ -30,6 +33,7 @@ import {
|
|
|
30
33
|
DEFAULT_NAVIGATION_EXCLUDE,
|
|
31
34
|
DEFAULT_PREWARM,
|
|
32
35
|
DEFAULT_PREWARM_SKIP,
|
|
36
|
+
DEFAULT_SECURITY,
|
|
33
37
|
DEFAULT_STATIC,
|
|
34
38
|
} from "./defaults.js";
|
|
35
39
|
|
|
@@ -68,6 +72,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
68
72
|
* @property {string[]} devGateBypass
|
|
69
73
|
* @property {string[]} preconnect
|
|
70
74
|
* @property {NavigationConfig} navigation
|
|
75
|
+
* @property {SecurityConfig} security
|
|
71
76
|
* @property {string[]} prewarmSkip
|
|
72
77
|
* @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
|
|
73
78
|
* @property {{ family: string, slug?: string, weights: number[] }[]} fonts
|
|
@@ -87,7 +92,7 @@ let config = null;
|
|
|
87
92
|
function asArray(value, label) {
|
|
88
93
|
if (value == null) return [];
|
|
89
94
|
if (Array.isArray(value)) return value;
|
|
90
|
-
console.warn(`[config] ${label}
|
|
95
|
+
console.warn(`[config] ${label} must return an array, ignoring it`);
|
|
91
96
|
return [];
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -201,7 +206,7 @@ function normalizeEagerness(value, fallback, label) {
|
|
|
201
206
|
}
|
|
202
207
|
|
|
203
208
|
console.warn(
|
|
204
|
-
`[config] navigation.${label}
|
|
209
|
+
`[config] navigation.${label} is invalid (${String(value)}), falling back to the default`,
|
|
205
210
|
);
|
|
206
211
|
return fallback;
|
|
207
212
|
}
|
|
@@ -240,6 +245,50 @@ function normalizeNavigation(raw, brand) {
|
|
|
240
245
|
};
|
|
241
246
|
}
|
|
242
247
|
|
|
248
|
+
/**
|
|
249
|
+
* @typedef {object} SecurityConfig
|
|
250
|
+
* @property {boolean} trustProxy
|
|
251
|
+
* @property {string | null} cookieSecret
|
|
252
|
+
* @property {{ enabled: boolean, token: boolean, allowedOrigins: string[],
|
|
253
|
+
* exclude: CompiledPattern[], cookieName: string, fieldName: string,
|
|
254
|
+
* headerName: string }} csrf
|
|
255
|
+
*/
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Güvenlik bölümü. `csrf.exclude` desenleri burada derlenir: her istekte
|
|
259
|
+
* yeniden derlemek gereksiz, ve bozuk bir desen sunucuyu düşürmemeli.
|
|
260
|
+
*
|
|
261
|
+
* @param {unknown} raw
|
|
262
|
+
* @returns {SecurityConfig}
|
|
263
|
+
*/
|
|
264
|
+
function normalizeSecurity(raw) {
|
|
265
|
+
const source = /** @type {Record<string, any>} */ (raw ?? {});
|
|
266
|
+
const csrf = { ...DEFAULT_SECURITY.csrf, ...(source.csrf ?? {}) };
|
|
267
|
+
|
|
268
|
+
const exclude = asArray(csrf.exclude, "security.csrf.exclude")
|
|
269
|
+
.map((entry) => compilePattern(entry))
|
|
270
|
+
.filter((pattern) => pattern !== null);
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
trustProxy: source.trustProxy !== false,
|
|
274
|
+
cookieSecret:
|
|
275
|
+
typeof source.cookieSecret === "string" && source.cookieSecret
|
|
276
|
+
? source.cookieSecret
|
|
277
|
+
: null,
|
|
278
|
+
csrf: {
|
|
279
|
+
enabled: csrf.enabled !== false,
|
|
280
|
+
token: csrf.token === true,
|
|
281
|
+
allowedOrigins: asArray(csrf.allowedOrigins, "security.csrf.allowedOrigins")
|
|
282
|
+
.filter((entry) => typeof entry === "string")
|
|
283
|
+
.map(String),
|
|
284
|
+
exclude: /** @type {CompiledPattern[]} */ (exclude),
|
|
285
|
+
cookieName: String(csrf.cookieName ?? DEFAULT_SECURITY.csrf.cookieName),
|
|
286
|
+
fieldName: String(csrf.fieldName ?? DEFAULT_SECURITY.csrf.fieldName),
|
|
287
|
+
headerName: String(csrf.headerName ?? DEFAULT_SECURITY.csrf.headerName).toLowerCase(),
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
243
292
|
/**
|
|
244
293
|
* Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
|
|
245
294
|
* de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
|
|
@@ -307,7 +356,7 @@ export async function loadConfig(options = {}) {
|
|
|
307
356
|
|
|
308
357
|
if (!fs.existsSync(configPath)) {
|
|
309
358
|
console.warn(
|
|
310
|
-
`[config] ${configFile}
|
|
359
|
+
`[config] ${configFile} not found — continuing with built-in defaults.`,
|
|
311
360
|
);
|
|
312
361
|
} else {
|
|
313
362
|
try {
|
|
@@ -316,7 +365,7 @@ export async function loadConfig(options = {}) {
|
|
|
316
365
|
source = module.default ?? module;
|
|
317
366
|
loaded = true;
|
|
318
367
|
} catch (error) {
|
|
319
|
-
console.warn(`[config] ${configFile}
|
|
368
|
+
console.warn(`[config] ${configFile} failed to load, ignoring it`, error);
|
|
320
369
|
}
|
|
321
370
|
}
|
|
322
371
|
|
|
@@ -327,7 +376,7 @@ export async function loadConfig(options = {}) {
|
|
|
327
376
|
try {
|
|
328
377
|
return typeof value === "function" ? await value.call(source) : value;
|
|
329
378
|
} catch (error) {
|
|
330
|
-
console.warn(`[config] ${name}()
|
|
379
|
+
console.warn(`[config] ${name}() threw, ignoring it`, error);
|
|
331
380
|
return null;
|
|
332
381
|
}
|
|
333
382
|
};
|
|
@@ -363,6 +412,7 @@ export async function loadConfig(options = {}) {
|
|
|
363
412
|
devGateBypass: source.devGateBypass ?? DEFAULT_DEV_GATE_BYPASS,
|
|
364
413
|
preconnect: source.preconnect ?? [],
|
|
365
414
|
navigation: normalizeNavigation(source.navigation, brand),
|
|
415
|
+
security: normalizeSecurity(source.security),
|
|
366
416
|
prewarmSkip: source.prewarmSkip ?? DEFAULT_PREWARM_SKIP,
|
|
367
417
|
// `routes`, `views` ve `lib` zaten izlenir; buraya yalnızca ek dizinler.
|
|
368
418
|
watch: source.watch ?? [],
|
|
@@ -377,15 +427,20 @@ export async function loadConfig(options = {}) {
|
|
|
377
427
|
// Dev'de build ve sunucu ayrı alt süreçler; üçü de aynı özeti basınca satır
|
|
378
428
|
// banner'ın ve build bloğunun arasına üç kez giriyor. Özeti dış süreç basar.
|
|
379
429
|
if (loaded && !process.env.JSKELET_CHILD) {
|
|
430
|
+
/** @param {number} count @param {string} singular @param {string} plural */
|
|
431
|
+
const label = (count, singular, plural) =>
|
|
432
|
+
`${count} ${count === 1 ? singular : plural}`;
|
|
433
|
+
|
|
380
434
|
const counts = [
|
|
381
|
-
config.headers.length &&
|
|
382
|
-
config.redirects.length &&
|
|
383
|
-
|
|
384
|
-
config.
|
|
435
|
+
config.headers.length && label(config.headers.length, "header", "headers"),
|
|
436
|
+
config.redirects.length &&
|
|
437
|
+
label(config.redirects.length, "redirect", "redirects"),
|
|
438
|
+
config.rewrites.length && label(config.rewrites.length, "rewrite", "rewrites"),
|
|
439
|
+
config.html.length && label(config.html.length, "cache rule", "cache rules"),
|
|
385
440
|
].filter(Boolean);
|
|
386
441
|
|
|
387
442
|
if (counts.length) {
|
|
388
|
-
console.log(`[config] ${configFile}
|
|
443
|
+
console.log(`[config] ${configFile} loaded — ${counts.join(", ")}`);
|
|
389
444
|
}
|
|
390
445
|
}
|
|
391
446
|
|
|
@@ -402,8 +457,8 @@ export async function loadConfig(options = {}) {
|
|
|
402
457
|
export function getConfig() {
|
|
403
458
|
if (!config) {
|
|
404
459
|
throw new Error(
|
|
405
|
-
"[config]
|
|
406
|
-
"
|
|
460
|
+
"[config] getConfig() was used before loadConfig(). " +
|
|
461
|
+
"Start the server with the `jskelet` CLI or through createApp().",
|
|
407
462
|
);
|
|
408
463
|
}
|
|
409
464
|
return config;
|
|
@@ -427,7 +482,7 @@ export async function hook(name, fallback, ...args) {
|
|
|
427
482
|
try {
|
|
428
483
|
return await fn(...args);
|
|
429
484
|
} catch (error) {
|
|
430
|
-
console.warn(`[config] hooks.${name}()
|
|
485
|
+
console.warn(`[config] hooks.${name}() threw, using the default`, error);
|
|
431
486
|
return fallback;
|
|
432
487
|
}
|
|
433
488
|
}
|
package/src/config/pattern.js
CHANGED
|
@@ -28,7 +28,7 @@ function escapeLiteral(text) {
|
|
|
28
28
|
*/
|
|
29
29
|
export function compilePattern(source) {
|
|
30
30
|
if (typeof source !== "string" || !source.startsWith("/")) {
|
|
31
|
-
console.warn(`[config]
|
|
31
|
+
console.warn(`[config] invalid source (must start with \`/\`): ${source}`);
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
|
package/src/dev-server.mjs
CHANGED
|
@@ -350,7 +350,7 @@ function watchSources() {
|
|
|
350
350
|
});
|
|
351
351
|
} catch {
|
|
352
352
|
log.warn(
|
|
353
|
-
|
|
353
|
+
`could not watch ${path.relative(ROOT, target)}; no auto restart for this directory.`,
|
|
354
354
|
);
|
|
355
355
|
}
|
|
356
356
|
}
|
package/src/http/control-flow.js
CHANGED
|
@@ -14,7 +14,7 @@ export class NotFoundError extends Error {
|
|
|
14
14
|
export class RedirectError extends Error {
|
|
15
15
|
/**
|
|
16
16
|
* @param {string} location
|
|
17
|
-
* @param {301 | 302 | 307 | 308} [statusCode]
|
|
17
|
+
* @param {301 | 302 | 303 | 307 | 308} [statusCode]
|
|
18
18
|
*/
|
|
19
19
|
constructor(location, statusCode = 307) {
|
|
20
20
|
super(`Redirect to ${location}`);
|
|
@@ -45,6 +45,21 @@ export function redirect(location) {
|
|
|
45
45
|
throw new RedirectError(location, 307);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* POST sonrası yönlendirme (303 See Other).
|
|
50
|
+
*
|
|
51
|
+
* `redirect()` 307 kullanır ve 307 **metodu korur**: bir POST handler'ından
|
|
52
|
+
* çağrıldığında tarayıcı hedefe yeniden POST eder. Form gönderiminden sonra
|
|
53
|
+
* sayfayı GET olarak açmak — yani geri tuşunun formu yeniden göndermediği
|
|
54
|
+
* klasik "post/redirect/get" akışı — 303 gerektiriyor.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} location
|
|
57
|
+
* @returns {never}
|
|
58
|
+
*/
|
|
59
|
+
export function seeOther(location) {
|
|
60
|
+
throw new RedirectError(location, 303);
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
/** @param {unknown} error */
|
|
49
64
|
export function isNotFoundError(error) {
|
|
50
65
|
return error instanceof NotFoundError;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie okuma/yazma ve HMAC ile imzalama.
|
|
3
|
+
*
|
|
4
|
+
* Neden framework'te: kişiye özel her sayfa bir oturum cookie'sine dayanıyor
|
|
5
|
+
* ve bunu elle yazan her proje aynı üç hatayı tekrar ediyor — `HttpOnly`
|
|
6
|
+
* unutmak, imzasız değere güvenmek, karşılaştırmayı `===` ile yapmak.
|
|
7
|
+
* Burada varsayılanlar güvenli tarafta ve imza doğrulaması sabit zamanlı.
|
|
8
|
+
*
|
|
9
|
+
* Framework **kimlik sağlamaz**: oturumun içinde ne olduğu, ne kadar
|
|
10
|
+
* yaşadığı ve kimin verdiği uygulamanın kararı. Buradaki yüzey yalnızca
|
|
11
|
+
* "bu değeri ben yazdım, kurcalanmamış" garantisini veriyor.
|
|
12
|
+
*
|
|
13
|
+
* Bağımlılık eklenmez; `node:crypto` yeterli.
|
|
14
|
+
*/
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
import { getConfig } from "../config/index.js";
|
|
18
|
+
import { markTainted } from "./request-context.js";
|
|
19
|
+
|
|
20
|
+
/** Ayrıştırılmış cookie'ler istek başına bir kez hesaplanır. */
|
|
21
|
+
const PARSED = Symbol("jskelet.cookies");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} value
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function base64url(value) {
|
|
28
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} value
|
|
33
|
+
* @returns {string | null}
|
|
34
|
+
*/
|
|
35
|
+
function fromBase64url(value) {
|
|
36
|
+
try {
|
|
37
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* İmza sırrı. `security.cookieSecret` ya da `JSKELET_SECRET`.
|
|
45
|
+
*
|
|
46
|
+
* Yokluğunda imzasız cookie yazmak en kötü sonuç olurdu: uygulama kendini
|
|
47
|
+
* güvende sanar, değer kurcalanabilir. Bu yüzden imzalı API sır olmadan
|
|
48
|
+
* hata verir — config hatasının siteyi düşürmemesi kuralı burada geçmez,
|
|
49
|
+
* çünkü sessiz alternatif bir güvenlik açığı.
|
|
50
|
+
*
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function getSecret() {
|
|
54
|
+
/** @type {string | null} */
|
|
55
|
+
let configured = null;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
configured = getConfig().security.cookieSecret;
|
|
59
|
+
} catch {
|
|
60
|
+
// Config yüklenmemiş olabilir (script, test); env yine de geçerli.
|
|
61
|
+
configured = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const secret = configured ?? process.env.JSKELET_SECRET ?? null;
|
|
65
|
+
|
|
66
|
+
if (!secret) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"[cookies] a secret is required for signed cookies. Set " +
|
|
69
|
+
"`security.cookieSecret` in `jskelet.config.mjs` or the JSKELET_SECRET environment variable.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return secret;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} value
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function sign(value) {
|
|
81
|
+
return crypto.createHmac("sha256", getSecret()).update(value).digest("base64url");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sabit zamanlı karşılaştırma: imza doğrulamasında erken çıkış, saldırganın
|
|
86
|
+
* baytları tek tek tahmin etmesine kapı aralar.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} a
|
|
89
|
+
* @param {string} b
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function safeEqual(a, b) {
|
|
93
|
+
const left = Buffer.from(String(a));
|
|
94
|
+
const right = Buffer.from(String(b));
|
|
95
|
+
if (left.length !== right.length) return false;
|
|
96
|
+
return crypto.timingSafeEqual(left, right);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `Cookie` başlığını ayrıştırır.
|
|
101
|
+
*
|
|
102
|
+
* Okuma çıktının kullanıcıya bağlı olduğunu bildirir: bu sayfa artık public
|
|
103
|
+
* HTML cache'ine yazılamaz.
|
|
104
|
+
*
|
|
105
|
+
* @param {import('http').IncomingMessage} req
|
|
106
|
+
* @returns {Record<string, string>}
|
|
107
|
+
*/
|
|
108
|
+
export function parseCookies(req) {
|
|
109
|
+
markTainted("parseCookies(req)");
|
|
110
|
+
|
|
111
|
+
const cached = /** @type {any} */ (req)[PARSED];
|
|
112
|
+
if (cached) return cached;
|
|
113
|
+
|
|
114
|
+
/** @type {Record<string, string>} */
|
|
115
|
+
const out = {};
|
|
116
|
+
const header = req.headers?.cookie;
|
|
117
|
+
|
|
118
|
+
if (header) {
|
|
119
|
+
for (const part of header.split(";")) {
|
|
120
|
+
const index = part.indexOf("=");
|
|
121
|
+
if (index === -1) continue;
|
|
122
|
+
|
|
123
|
+
const name = part.slice(0, index).trim();
|
|
124
|
+
if (!name) continue;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
out[name] = decodeURIComponent(part.slice(index + 1).trim());
|
|
128
|
+
} catch {
|
|
129
|
+
// Bozuk yüzde kodlaması tüm başlığı çöpe atmamalı.
|
|
130
|
+
out[name] = part.slice(index + 1).trim();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @type {any} */ (req)[PARSED] = out;
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {object} CookieOptions
|
|
141
|
+
* @property {string} [path] Varsayılan `/`.
|
|
142
|
+
* @property {string} [domain]
|
|
143
|
+
* @property {number} [maxAge] Saniye.
|
|
144
|
+
* @property {Date} [expires]
|
|
145
|
+
* @property {boolean} [httpOnly] Varsayılan `true`.
|
|
146
|
+
* @property {boolean} [secure] Varsayılan: development dışında `true`.
|
|
147
|
+
* @property {"Strict" | "Lax" | "None"} [sameSite] Varsayılan `Lax`.
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Varsayılanlar bilinçli olarak kısıtlayıcı: `HttpOnly` ile JS okuyamaz,
|
|
152
|
+
* `SameSite=Lax` ile çapraz site POST'larında gönderilmez (CSRF'nin büyük
|
|
153
|
+
* kısmını kapatan tek satır), `Secure` üretimde açık.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} name
|
|
156
|
+
* @param {string} value
|
|
157
|
+
* @param {CookieOptions} [options]
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
export function serializeCookie(name, value, options = {}) {
|
|
161
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
162
|
+
|
|
163
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
164
|
+
if (options.domain) parts.push(`Domain=${options.domain}`);
|
|
165
|
+
if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
|
166
|
+
if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
167
|
+
if (options.httpOnly !== false) parts.push("HttpOnly");
|
|
168
|
+
if (options.secure ?? process.env.NODE_ENV !== "development") parts.push("Secure");
|
|
169
|
+
parts.push(`SameSite=${options.sameSite ?? "Lax"}`);
|
|
170
|
+
|
|
171
|
+
return parts.join("; ");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {import('http').ServerResponse} res
|
|
176
|
+
* @param {string} name
|
|
177
|
+
* @param {string} value
|
|
178
|
+
* @param {CookieOptions} [options]
|
|
179
|
+
*/
|
|
180
|
+
export function setCookie(res, name, value, options = {}) {
|
|
181
|
+
const existing = res.getHeader("Set-Cookie");
|
|
182
|
+
const serialized = serializeCookie(name, value, options);
|
|
183
|
+
|
|
184
|
+
/** @type {string[]} */
|
|
185
|
+
const all = existing
|
|
186
|
+
? Array.isArray(existing)
|
|
187
|
+
? [...existing.map(String)]
|
|
188
|
+
: [String(existing)]
|
|
189
|
+
: [];
|
|
190
|
+
|
|
191
|
+
all.push(serialized);
|
|
192
|
+
res.setHeader("Set-Cookie", all);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {import('http').ServerResponse} res
|
|
197
|
+
* @param {string} name
|
|
198
|
+
* @param {CookieOptions} [options]
|
|
199
|
+
*/
|
|
200
|
+
export function clearCookie(res, name, options = {}) {
|
|
201
|
+
setCookie(res, name, "", { ...options, maxAge: 0, expires: new Date(0) });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* İmzalı cookie yazar. Değer okunabilir kalır (şifreleme değil, imza);
|
|
206
|
+
* gizli kalması gereken veriyi cookie'ye koymayın, kimliğini koyun.
|
|
207
|
+
*
|
|
208
|
+
* @param {import('http').ServerResponse} res
|
|
209
|
+
* @param {string} name
|
|
210
|
+
* @param {string} value
|
|
211
|
+
* @param {CookieOptions} [options]
|
|
212
|
+
*/
|
|
213
|
+
export function setSignedCookie(res, name, value, options = {}) {
|
|
214
|
+
const encoded = base64url(value);
|
|
215
|
+
setCookie(res, name, `${encoded}.${sign(encoded)}`, options);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* İmzalı cookie okur. İmza uymuyorsa `null` — bozuk imza, yok sayılmalı,
|
|
220
|
+
* "belki geçerlidir" diye kullanılmamalı.
|
|
221
|
+
*
|
|
222
|
+
* @param {import('http').IncomingMessage} req
|
|
223
|
+
* @param {string} name
|
|
224
|
+
* @returns {string | null}
|
|
225
|
+
*/
|
|
226
|
+
export function getSignedCookie(req, name) {
|
|
227
|
+
const raw = parseCookies(req)[name];
|
|
228
|
+
if (!raw) return null;
|
|
229
|
+
|
|
230
|
+
const index = raw.lastIndexOf(".");
|
|
231
|
+
if (index <= 0) return null;
|
|
232
|
+
|
|
233
|
+
const encoded = raw.slice(0, index);
|
|
234
|
+
const signature = raw.slice(index + 1);
|
|
235
|
+
|
|
236
|
+
let expected;
|
|
237
|
+
try {
|
|
238
|
+
expected = sign(encoded);
|
|
239
|
+
} catch {
|
|
240
|
+
// Sır yoksa imzalı okuma sessizce başarısız olur: sunucu ayakta kalır
|
|
241
|
+
// ama hiçbir oturum geçerli sayılmaz.
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!safeEqual(signature, expected)) return null;
|
|
246
|
+
return fromBase64url(encoded);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Kriptografik rastgele token. CSRF token'ı ve oturum kimliği için.
|
|
251
|
+
*
|
|
252
|
+
* @param {number} [bytes]
|
|
253
|
+
* @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
export function randomToken(bytes = 32) {
|
|
256
|
+
return crypto.randomBytes(bytes).toString("base64url");
|
|
257
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* İstek başına yaşayan bağlam.
|
|
3
|
+
*
|
|
4
|
+
* `request-cache.js` ile aynı desen: `AsyncLocalStorage`, bağlam yoksa her şey
|
|
5
|
+
* sessizce devre dışı. Ayrı bir modül olmasının sebebi, taşıdığı bilginin
|
|
6
|
+
* memoizasyondan farklı olması — burada tutulanlar bir isteğin **cache'lenip
|
|
7
|
+
* cache'lenemeyeceğine** dair kararlar:
|
|
8
|
+
*
|
|
9
|
+
* private → route açıkça "bu sayfa kişiye özel" dedi
|
|
10
|
+
* tainted → controller cookie/Authorization okudu, yani çıktı kullanıcıya
|
|
11
|
+
* bağlı olabilir; HTML cache'e yazmak sızıntı olur
|
|
12
|
+
* csrfToken → bu istek için üretilmiş token; `csrfField()` şablonda basar
|
|
13
|
+
* res → token cookie'sini yazabilmek için; başka hiçbir şey için
|
|
14
|
+
* kullanılmaz, şablonlara yanıt nesnesi sızmaz
|
|
15
|
+
*
|
|
16
|
+
* `tainted` bir tahmin değil, gözlem: `markTainted()` yalnızca gerçekten
|
|
17
|
+
* kimliğe dokunan bir erişimden sonra çağrılır (bkz. `guardRequest`).
|
|
18
|
+
*/
|
|
19
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} RequestContext
|
|
23
|
+
* @property {boolean} private Route `private: true` ile mi kaydedildi.
|
|
24
|
+
* @property {boolean} tainted Kimliğe bağlı bir veri okundu mu.
|
|
25
|
+
* @property {string[]} taintReasons Hangi erişimler işaretledi (teşhis için).
|
|
26
|
+
* @property {string | null} csrfToken
|
|
27
|
+
* @property {import('express').Response | null} res
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** @type {AsyncLocalStorage<RequestContext>} */
|
|
31
|
+
const storage = new AsyncLocalStorage();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ private?: boolean, res?: import('express').Response }} [initial]
|
|
35
|
+
* @returns {RequestContext}
|
|
36
|
+
*/
|
|
37
|
+
export function createRequestContext(initial = {}) {
|
|
38
|
+
return {
|
|
39
|
+
private: initial.private === true,
|
|
40
|
+
tainted: false,
|
|
41
|
+
taintReasons: [],
|
|
42
|
+
csrfToken: null,
|
|
43
|
+
res: initial.res ?? null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* İsteği verilen bağlam içinde çalıştırır.
|
|
49
|
+
*
|
|
50
|
+
* @template T
|
|
51
|
+
* @param {RequestContext} context
|
|
52
|
+
* @param {() => T} run
|
|
53
|
+
* @returns {T}
|
|
54
|
+
*/
|
|
55
|
+
export function withRequestContext(context, run) {
|
|
56
|
+
return storage.run(context, run);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @returns {RequestContext | undefined}
|
|
61
|
+
*/
|
|
62
|
+
export function getRequestContext() {
|
|
63
|
+
return storage.getStore();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Çıktının kullanıcıya bağlı olduğunu bildirir. Bağlam yoksa (script, build,
|
|
68
|
+
* fragment dışı kullanım) sessizce yok sayılır.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} reason Teşhis mesajında görünecek erişim adı.
|
|
71
|
+
*/
|
|
72
|
+
export function markTainted(reason) {
|
|
73
|
+
const context = storage.getStore();
|
|
74
|
+
if (!context) return;
|
|
75
|
+
|
|
76
|
+
context.tainted = true;
|
|
77
|
+
if (!context.taintReasons.includes(reason)) {
|
|
78
|
+
context.taintReasons.push(reason);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
/** Okunması çıktıyı kullanıcıya bağlayan başlıklar. */
|
|
84
|
+
const SENSITIVE_HEADERS = new Set([
|
|
85
|
+
"cookie",
|
|
86
|
+
"authorization",
|
|
87
|
+
"proxy-authorization",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/** Okunması çıktıyı kullanıcıya bağlayan `req` alanları. */
|
|
91
|
+
const SENSITIVE_PROPS = new Set(["cookies", "signedCookies", "session", "user"]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {Record<string, unknown>} headers
|
|
95
|
+
* @returns {Record<string, unknown>}
|
|
96
|
+
*/
|
|
97
|
+
function guardHeaders(headers) {
|
|
98
|
+
return new Proxy(headers, {
|
|
99
|
+
get(target, prop, receiver) {
|
|
100
|
+
if (typeof prop === "string" && SENSITIVE_HEADERS.has(prop.toLowerCase())) {
|
|
101
|
+
markTainted(`req.headers.${prop}`);
|
|
102
|
+
}
|
|
103
|
+
return Reflect.get(target, prop, receiver);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Controller'a giden `req`'i kimliğe dokunan erişimleri işaretleyen bir Proxy
|
|
110
|
+
* ile sarar.
|
|
111
|
+
*
|
|
112
|
+
* Neden gerekli: HTML cache anahtarı yalnızca yol + query. Cookie okuyan bir
|
|
113
|
+
* controller cache'lenebilir bir route'a bağlanmışsa bir kullanıcının HTML'i
|
|
114
|
+
* bir başkasına servis edilir ve bu hiçbir yerde hata olarak görünmez.
|
|
115
|
+
* İşaretleme, sessiz sızıntıyı gürültülü bir uyarıya çeviriyor.
|
|
116
|
+
*
|
|
117
|
+
* Kapsam bilinçli olarak dar: yalnızca *okuma* yakalanır, yazma ve metot
|
|
118
|
+
* çağrıları hedefe dokunulmadan geçer. Proxy'nin prototipi korunduğu için
|
|
119
|
+
* `req instanceof IncomingMessage` gibi kontroller etkilenmez.
|
|
120
|
+
*
|
|
121
|
+
* @param {import('express').Request} req
|
|
122
|
+
* @returns {import('express').Request}
|
|
123
|
+
*/
|
|
124
|
+
export function guardRequest(req) {
|
|
125
|
+
/** @type {Record<string, unknown> | null} */
|
|
126
|
+
let headersProxy = null;
|
|
127
|
+
|
|
128
|
+
return new Proxy(req, {
|
|
129
|
+
get(target, prop, receiver) {
|
|
130
|
+
if (prop === "headers" || prop === "headersDistinct") {
|
|
131
|
+
const raw = Reflect.get(target, prop, receiver);
|
|
132
|
+
if (!raw || typeof raw !== "object") return raw;
|
|
133
|
+
if (prop === "headers") {
|
|
134
|
+
headersProxy ??= guardHeaders(/** @type {Record<string, unknown>} */ (raw));
|
|
135
|
+
return headersProxy;
|
|
136
|
+
}
|
|
137
|
+
return guardHeaders(/** @type {Record<string, unknown>} */ (raw));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (prop === "get" || prop === "header") {
|
|
141
|
+
const original = Reflect.get(target, prop, receiver);
|
|
142
|
+
if (typeof original !== "function") return original;
|
|
143
|
+
return function guardedHeaderLookup(/** @type {string} */ name) {
|
|
144
|
+
if (typeof name === "string" && SENSITIVE_HEADERS.has(name.toLowerCase())) {
|
|
145
|
+
markTainted(`req.get("${name}")`);
|
|
146
|
+
}
|
|
147
|
+
return original.call(target, name);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (typeof prop === "string" && SENSITIVE_PROPS.has(prop)) {
|
|
152
|
+
markTainted(`req.${prop}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const value = Reflect.get(target, prop, receiver);
|
|
156
|
+
// Express metotları kendi iç alanlarına `this` üzerinden erişiyor;
|
|
157
|
+
// proxy'yi receiver olarak bırakmak bu erişimleri de yakalayıp
|
|
158
|
+
// gereksiz işaretlemeye yol açar.
|
|
159
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
package/src/index.js
CHANGED
|
@@ -5,19 +5,36 @@
|
|
|
5
5
|
* controller'lar bu yüzeyi kullanır. Alt yollardan (`jskelet/server`)
|
|
6
6
|
* ithal etmek de mümkün; buradaki liste "kararlı" sayılan yüzeydir.
|
|
7
7
|
*/
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
route,
|
|
10
|
+
fragment,
|
|
11
|
+
renderPage,
|
|
12
|
+
renderView,
|
|
13
|
+
renderNotFound,
|
|
14
|
+
} from "./server/render.js";
|
|
9
15
|
export { renderStatusPage, statusFromError } from "./server/status-page.js";
|
|
10
16
|
export { createApp, startServer } from "./server/create-app.js";
|
|
11
17
|
export {
|
|
12
18
|
notFound,
|
|
13
19
|
redirect,
|
|
14
20
|
permanentRedirect,
|
|
21
|
+
seeOther,
|
|
15
22
|
isNotFoundError,
|
|
16
23
|
isRedirectError,
|
|
17
24
|
NotFoundError,
|
|
18
25
|
RedirectError,
|
|
19
26
|
} from "./http/control-flow.js";
|
|
20
27
|
export { cache, withRequestCache } from "./http/request-cache.js";
|
|
28
|
+
export {
|
|
29
|
+
clearCookie,
|
|
30
|
+
getSignedCookie,
|
|
31
|
+
parseCookies,
|
|
32
|
+
randomToken,
|
|
33
|
+
safeEqual,
|
|
34
|
+
serializeCookie,
|
|
35
|
+
setCookie,
|
|
36
|
+
setSignedCookie,
|
|
37
|
+
} from "./http/cookies.js";
|
|
21
38
|
export { reportUpstreamFailure } from "./server/upstream-tracking.js";
|
|
22
39
|
export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
|
|
23
40
|
export { headHints } from "./server/head-hints.js";
|
|
@@ -32,4 +49,4 @@ export { prewarm, prewarmProgress } from "./server/prewarm.js";
|
|
|
32
49
|
export { createProxy } from "./server/middleware/upstream-proxy.js";
|
|
33
50
|
export { getConfig, loadConfig } from "./config/index.js";
|
|
34
51
|
export { attrs, cn, cx, esc, jsonScript } from "./views/helpers/html.js";
|
|
35
|
-
export { icon, image, link, preloadImage } from "./views/helpers/tags.js";
|
|
52
|
+
export { csrfField, icon, image, link, preloadImage } from "./views/helpers/tags.js";
|