jskelet 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/AGENTS.md +18 -13
  2. package/CHANGELOG.md +387 -385
  3. package/README.md +9 -7
  4. package/bin/jskelet.mjs +24 -10
  5. package/docs/01-baslangic.md +4 -3
  6. package/docs/02-mimari.md +4 -3
  7. package/docs/03-routing.md +11 -6
  8. package/docs/04-render-ve-sablonlar.md +35 -43
  9. package/docs/05-islands.md +12 -8
  10. package/docs/07-yapilandirma.md +53 -25
  11. package/docs/08-build.md +40 -18
  12. package/docs/09-dev-araclari.md +5 -1
  13. package/docs/10-dagitim.md +6 -1
  14. package/docs/11-tasima.md +51 -17
  15. package/docs/12-panel-ve-oturum.md +10 -4
  16. package/docs/README.md +7 -5
  17. package/docs/en/01-getting-started.md +4 -3
  18. package/docs/en/02-architecture.md +5 -5
  19. package/docs/en/03-routing.md +12 -7
  20. package/docs/en/04-rendering.md +47 -59
  21. package/docs/en/05-islands.md +13 -8
  22. package/docs/en/07-configuration.md +55 -27
  23. package/docs/en/08-build.md +43 -21
  24. package/docs/en/09-dev-tools.md +6 -1
  25. package/docs/en/10-deployment.md +6 -1
  26. package/docs/en/11-migration.md +51 -16
  27. package/docs/en/12-dashboards-and-sessions.md +9 -4
  28. package/docs/en/README.md +7 -5
  29. package/package.json +49 -14
  30. package/src/build/tasks/client.mjs +91 -10
  31. package/src/build/tasks/icons.mjs +152 -18
  32. package/src/client/index.js +2 -2
  33. package/src/compile/codegen.js +4 -0
  34. package/src/compile/compile-all.js +12 -21
  35. package/src/compile/expr.js +5 -0
  36. package/src/compile/parse.js +64 -8
  37. package/src/compile/resolve.js +3 -0
  38. package/src/config/defaults.js +12 -2
  39. package/src/config/index.js +31 -3
  40. package/src/dev-server.mjs +26 -3
  41. package/src/http/cookies-entry.js +1 -0
  42. package/src/http/cookies.js +18 -0
  43. package/src/logo.png +0 -0
  44. package/src/migrate/apply.mjs +262 -0
  45. package/src/migrate/babel.mjs +79 -0
  46. package/src/migrate/classify.mjs +155 -0
  47. package/src/migrate/config.mjs +126 -0
  48. package/src/migrate/fs-walk.mjs +191 -0
  49. package/src/migrate/parse.mjs +26 -0
  50. package/src/migrate/scan.mjs +177 -0
  51. package/src/migrate/transform/expr-source.mjs +168 -0
  52. package/src/migrate/transform/island.mjs +67 -0
  53. package/src/migrate/transform/jsx-to-component.mjs +302 -0
  54. package/src/migrate/transform/jsx-to-jsk.mjs +330 -0
  55. package/src/migrate/transform/page-split.mjs +435 -0
  56. package/src/migrate/write.mjs +81 -0
  57. package/src/migrate.mjs +171 -0
  58. package/src/server/auth/handoff.js +94 -11
  59. package/src/server/create-app.js +28 -10
  60. package/src/server/ejs-adapter.js +59 -0
  61. package/src/server/image-optimizer.js +94 -26
  62. package/src/server/port-guard.js +255 -0
  63. package/src/server/render.js +27 -9
  64. package/src/server/status-page.js +105 -4
  65. package/src/start.mjs +18 -3
  66. package/src/templates/layout.ejs +8 -28
  67. package/src/templates/layout.jsk +30 -0
  68. package/src/templates/layout.render.js +41 -0
  69. package/src/views/helpers/tags.js +86 -3
  70. package/types/build/resolve-peer.d.mts +13 -0
  71. package/types/client/dom.d.ts +55 -0
  72. package/types/client/form.d.ts +19 -0
  73. package/types/client/index.d.ts +20 -0
  74. package/types/client/registry.d.ts +53 -0
  75. package/types/client/safe-image.d.ts +19 -0
  76. package/types/client/shared-cookie.d.ts +82 -0
  77. package/types/client/store.d.ts +18 -0
  78. package/types/client/swap.d.ts +46 -0
  79. package/types/compile/codegen.d.ts +32 -0
  80. package/types/compile/compile-all.d.ts +42 -0
  81. package/types/compile/errors.d.ts +30 -0
  82. package/types/compile/expr.d.ts +67 -0
  83. package/types/compile/index.d.ts +10 -0
  84. package/types/compile/parse.d.ts +82 -0
  85. package/types/compile/resolve.d.ts +46 -0
  86. package/types/compile/scan-exports.d.ts +9 -0
  87. package/types/config/defaults.d.ts +449 -0
  88. package/types/config/index.d.ts +299 -0
  89. package/types/config/pattern.d.ts +38 -0
  90. package/types/http/control-flow.d.ts +45 -0
  91. package/types/http/cookies-entry.d.ts +5 -0
  92. package/types/http/cookies.d.ts +113 -0
  93. package/types/http/request-cache.d.ts +13 -0
  94. package/types/http/request-context.d.ts +67 -0
  95. package/types/http/shared-cookie.d.ts +73 -0
  96. package/types/index.d.ts +30 -0
  97. package/types/log.d.mts +153 -0
  98. package/types/server/admin/actions.d.ts +16 -0
  99. package/types/server/admin/auth.d.ts +52 -0
  100. package/types/server/admin/event-log.d.ts +38 -0
  101. package/types/server/admin/gate.d.ts +43 -0
  102. package/types/server/admin/inventory.d.ts +40 -0
  103. package/types/server/admin/mount.d.ts +6 -0
  104. package/types/server/admin/router.d.ts +6 -0
  105. package/types/server/admin/snapshot.d.ts +6 -0
  106. package/types/server/assets.d.ts +47 -0
  107. package/types/server/auth/handoff.d.ts +12 -0
  108. package/types/server/cache-deps.d.ts +16 -0
  109. package/types/server/cache-vary.d.ts +30 -0
  110. package/types/server/cloudflare.d.ts +163 -0
  111. package/types/server/create-app.d.ts +25 -0
  112. package/types/server/data-cache.d.ts +116 -0
  113. package/types/server/dev/devtools.d.ts +44 -0
  114. package/types/server/dev/report.d.ts +229 -0
  115. package/types/server/dev/socket.d.ts +17 -0
  116. package/types/server/dev/version-check.d.mts +15 -0
  117. package/types/server/ejs-adapter.d.ts +11 -0
  118. package/types/server/head-hints.d.ts +40 -0
  119. package/types/server/html-cache.d.ts +173 -0
  120. package/types/server/image-optimizer.d.ts +68 -0
  121. package/types/server/logs/access-middleware.d.ts +7 -0
  122. package/types/server/logs/file-sink.d.ts +17 -0
  123. package/types/server/logs/pipeline.d.ts +37 -0
  124. package/types/server/logs/s3-put.d.ts +85 -0
  125. package/types/server/logs/s3-sink.d.ts +26 -0
  126. package/types/server/metadata.d.ts +38 -0
  127. package/types/server/middleware/compression.d.ts +17 -0
  128. package/types/server/middleware/csrf.d.ts +4 -0
  129. package/types/server/middleware/dev-gate.d.ts +2 -0
  130. package/types/server/middleware/headers.d.ts +2 -0
  131. package/types/server/middleware/redirects.d.ts +2 -0
  132. package/types/server/middleware/static-precompressed.d.ts +5 -0
  133. package/types/server/middleware/trailing-slash.d.ts +11 -0
  134. package/types/server/middleware/upstream-proxy.d.ts +21 -0
  135. package/types/server/og-image.d.ts +149 -0
  136. package/types/server/port-guard.d.ts +50 -0
  137. package/types/server/prewarm.d.ts +128 -0
  138. package/types/server/redis.d.ts +163 -0
  139. package/types/server/render.d.ts +101 -0
  140. package/types/server/router.d.ts +5 -0
  141. package/types/server/status-page.d.ts +24 -0
  142. package/types/server/upstream-limiter.d.ts +123 -0
  143. package/types/server/upstream-tracking.d.ts +42 -0
  144. package/types/shared/cookie-domain.d.ts +29 -0
  145. package/types/templates/layout.render.d.ts +7 -0
  146. package/types/version.d.mts +10 -0
  147. package/types/views/components/loader.d.ts +5 -0
  148. package/types/views/helpers/html.d.ts +39 -0
  149. package/types/views/helpers/tags.d.ts +127 -0
@@ -9,9 +9,12 @@
9
9
  *
10
10
  * Bilet süreç belleğinde; tek süreçte tüm locale host'ları servis eden
11
11
  * kurulumlar için yeterli. Değer kısa session id olmalı.
12
+ *
13
+ * Güvenlik: mint CSRF'den *sonra* mount edilir (create-app); cookie adı
14
+ * allowlist + RFC 6265 doğrulaması; bekleyen bilet ve IP başına mint sınırı.
12
15
  */
13
16
  import { getConfig } from "../../config/index.js";
14
- import { randomToken, setCookie } from "../../http/cookies.js";
17
+ import { isValidCookieName, randomToken, setCookie } from "../../http/cookies.js";
15
18
  import {
16
19
  SHARED_COOKIE_WARN_BYTES,
17
20
  requestIsHttps,
@@ -22,11 +25,26 @@ import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
22
25
  /** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
23
26
  const tickets = new Map();
24
27
 
28
+ /** IP → { count, windowStart } — basit mint rate limit. */
29
+ /** @type {Map<string, { count: number, windowStart: number }>} */
30
+ const mintWindows = new Map();
31
+
25
32
  const DEFAULT_TTL_SECONDS = 60;
33
+ const DEFAULT_MAX_PENDING = 256;
34
+ const DEFAULT_MAX_MINTS_PER_IP = 30;
35
+ const MINT_WINDOW_MS = 60_000;
26
36
  const HANDOFF_PATH = "/_jskelet/auth/handoff";
27
37
 
28
38
  /**
29
- * @returns {{ enabled: boolean, ttlSeconds: number, path: string, maxValueBytes: number }}
39
+ * @returns {{
40
+ * enabled: boolean,
41
+ * ttlSeconds: number,
42
+ * path: string,
43
+ * maxValueBytes: number,
44
+ * allowedCookieNames: string[],
45
+ * maxPendingTickets: number,
46
+ * maxMintsPerIpPerMinute: number,
47
+ * }}
30
48
  */
31
49
  function settings() {
32
50
  const auth = getConfig().auth ?? {};
@@ -37,21 +55,26 @@ function settings() {
37
55
  ttlSeconds: DEFAULT_TTL_SECONDS,
38
56
  path: HANDOFF_PATH,
39
57
  maxValueBytes: SHARED_COOKIE_WARN_BYTES,
58
+ allowedCookieNames: [],
59
+ maxPendingTickets: DEFAULT_MAX_PENDING,
60
+ maxMintsPerIpPerMinute: DEFAULT_MAX_MINTS_PER_IP,
40
61
  };
41
62
  }
42
63
 
43
- if (raw === true) {
44
- return {
45
- enabled: true,
46
- ttlSeconds: DEFAULT_TTL_SECONDS,
47
- path: HANDOFF_PATH,
48
- maxValueBytes: SHARED_COOKIE_WARN_BYTES,
49
- };
50
- }
64
+ /** @type {Record<string, unknown>} */
65
+ const source = raw === true ? {} : /** @type {Record<string, unknown>} */ (raw);
51
66
 
52
- const source = /** @type {Record<string, unknown>} */ (raw);
53
67
  const ttl = Number(source.ttlSeconds);
54
68
  const maxBytes = Number(source.maxValueBytes);
69
+ const maxPending = Number(source.maxPendingTickets);
70
+ const maxMints = Number(source.maxMintsPerIpPerMinute);
71
+
72
+ const namesRaw = source.allowedCookieNames;
73
+ /** @type {string[]} */
74
+ const allowedCookieNames = Array.isArray(namesRaw)
75
+ ? namesRaw.filter((n) => typeof n === "string" && isValidCookieName(n.trim())).map((n) => n.trim())
76
+ : [];
77
+
55
78
  return {
56
79
  enabled: source.enabled !== false,
57
80
  ttlSeconds:
@@ -64,6 +87,15 @@ function settings() {
64
87
  Number.isFinite(maxBytes) && maxBytes > 0
65
88
  ? Math.floor(maxBytes)
66
89
  : SHARED_COOKIE_WARN_BYTES,
90
+ allowedCookieNames,
91
+ maxPendingTickets:
92
+ Number.isFinite(maxPending) && maxPending > 0
93
+ ? Math.floor(maxPending)
94
+ : DEFAULT_MAX_PENDING,
95
+ maxMintsPerIpPerMinute:
96
+ Number.isFinite(maxMints) && maxMints > 0
97
+ ? Math.floor(maxMints)
98
+ : DEFAULT_MAX_MINTS_PER_IP,
67
99
  };
68
100
  }
69
101
 
@@ -74,6 +106,24 @@ function sweep() {
74
106
  }
75
107
  }
76
108
 
109
+ /**
110
+ * @param {string} ip
111
+ * @param {number} limit
112
+ * @returns {boolean} true ise mint'e izin var
113
+ */
114
+ function allowMint(ip, limit) {
115
+ const now = Date.now();
116
+ const key = ip || "unknown";
117
+ const entry = mintWindows.get(key);
118
+ if (!entry || now - entry.windowStart >= MINT_WINDOW_MS) {
119
+ mintWindows.set(key, { count: 1, windowStart: now });
120
+ return true;
121
+ }
122
+ if (entry.count >= limit) return false;
123
+ entry.count += 1;
124
+ return true;
125
+ }
126
+
77
127
  /**
78
128
  * @param {string} next
79
129
  * @param {import('express').Request} req
@@ -146,6 +196,7 @@ function redeem(ticketId) {
146
196
 
147
197
  /**
148
198
  * Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
199
+ * create-app CSRF'den *sonra* çağırmalıdır.
149
200
  *
150
201
  * @param {import('express').Express} app
151
202
  * @returns {void}
@@ -154,9 +205,27 @@ export function mountAuthHandoff(app) {
154
205
  const cfg = settings();
155
206
  if (!cfg.enabled) return;
156
207
 
208
+ if (cfg.allowedCookieNames.length === 0) {
209
+ console.warn(
210
+ "[auth.handoff] enabled but allowedCookieNames is empty — " +
211
+ "POST mint will return 400 until you list cookie names in " +
212
+ "auth.crossSubdomainHandoff.allowedCookieNames",
213
+ );
214
+ }
215
+
157
216
  app.post(cfg.path, (req, res) => {
158
217
  sweep();
159
218
 
219
+ if (!allowMint(String(req.ip || ""), cfg.maxMintsPerIpPerMinute)) {
220
+ res.status(429).json({ ok: false, error: "too many handoff requests" });
221
+ return;
222
+ }
223
+
224
+ if (tickets.size >= cfg.maxPendingTickets) {
225
+ res.status(503).json({ ok: false, error: "handoff ticket store is full" });
226
+ return;
227
+ }
228
+
160
229
  const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
161
230
  const value = typeof req.body?.value === "string" ? req.body.value : "";
162
231
  const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
@@ -170,6 +239,19 @@ export function mountAuthHandoff(app) {
170
239
  return;
171
240
  }
172
241
 
242
+ if (!isValidCookieName(name)) {
243
+ res.status(400).json({ ok: false, error: "invalid cookie name" });
244
+ return;
245
+ }
246
+
247
+ if (cfg.allowedCookieNames.length === 0 || !cfg.allowedCookieNames.includes(name)) {
248
+ res.status(400).json({
249
+ ok: false,
250
+ error: "cookie name is not in allowedCookieNames",
251
+ });
252
+ return;
253
+ }
254
+
173
255
  if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
174
256
  res.status(400).json({
175
257
  ok: false,
@@ -218,6 +300,7 @@ export function mountAuthHandoff(app) {
218
300
  /** @returns {void} */
219
301
  export function _resetHandoffTickets() {
220
302
  tickets.clear();
303
+ mintWindows.clear();
221
304
  }
222
305
 
223
306
  /** @returns {number} */
@@ -21,13 +21,12 @@
21
21
  * body parser'dan önce, admin ile aynı katmanda.
22
22
  * 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
23
23
  * maliyeti ödenmesin.
24
- * 5b. auth handoff (açıksa) — POST bilet + `?handoff=` redeem; csrf'den
25
- * önce değil sonra: JSON gövde parser'ı hazır olsun. Redeem GET route
26
- * render'ından önce olmalı.
27
- * 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
24
+ * 5b. csrf — body parser'lardan sonra: token form alanından okunuyor.
28
25
  * Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
29
- * 7. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
30
- * 8. route'lar → 404 → hata yönetimi.
26
+ * 5c. auth handoff (açıksa) — CSRF'den *sonra*: mint POST origin koruması
27
+ * görsün. Redeem GET güvenli metot; route render'ından önce kalır.
28
+ * 6. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
29
+ * 7. route'lar → 404 → hata yönetimi.
31
30
  */
32
31
  import path from "node:path";
33
32
  import process from "node:process";
@@ -51,6 +50,7 @@ import { configureUpstreamLimiter } from "./upstream-limiter.js";
51
50
  import { connectRedis, disconnectRedis } from "./redis.js";
52
51
  import { configureLogs, flushLogs, closeLogs } from "./logs/pipeline.js";
53
52
  import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
53
+ import { ensurePortFree } from "./port-guard.js";
54
54
 
55
55
  /**
56
56
  * @param {{ root?: string, configFile?: string }} [options]
@@ -152,6 +152,8 @@ export async function createApp(options = {}) {
152
152
  app.use(express.urlencoded({ extended: false, limit: "64kb" }));
153
153
  app.use(express.json({ limit: "256kb" }));
154
154
 
155
+ app.use(csrf());
156
+
155
157
  const handoff = config.auth?.crossSubdomainHandoff;
156
158
  if (
157
159
  handoff === true ||
@@ -163,8 +165,6 @@ export async function createApp(options = {}) {
163
165
  mountAuthHandoff(app);
164
166
  }
165
167
 
166
- app.use(csrf());
167
-
168
168
  app.use(configRewrites("afterFiles"));
169
169
 
170
170
  await registerRoutes(app);
@@ -222,12 +222,21 @@ export async function createApp(options = {}) {
222
222
  * Uygulamayı kurup dinlemeye başlar. CLI `jskelet start` bunu çağırır;
223
223
  * gömülü kullanımda `createApp()` tercih edilir.
224
224
  *
225
- * @param {{ root?: string, configFile?: string, port?: number, host?: string }} [options]
225
+ * Port doluysa başlamaz. `murder: true` veya argv'de `--murder` varsa
226
+ * dinleyen süreç öldürülüp bağlama denenir.
227
+ *
228
+ * @param {{ root?: string, configFile?: string, port?: number, host?: string, murder?: boolean }} [options]
226
229
  * @returns {Promise<import('http').Server>}
227
230
  */
228
231
  export async function startServer(options = {}) {
229
- const app = await createApp(options);
230
232
  const port = Number(options.port ?? process.env.PORT ?? 3000);
233
+ const murder =
234
+ options.murder === true || process.argv.includes("--murder");
235
+
236
+ // createApp pahalı; dolu portta uygulama kurmadan önce net hata ver.
237
+ await ensurePortFree(port, { murder });
238
+
239
+ const app = await createApp(options);
231
240
 
232
241
  // Varsayılan `::`, `0.0.0.0` değil: ikisi de "tüm arayüzler" demek, ama
233
242
  // yalnızca IPv6 soketi çift yığın çalışır ve `localhost`un `::1`e çözüldüğü
@@ -276,6 +285,15 @@ export async function startServer(options = {}) {
276
285
  listen("0.0.0.0");
277
286
  return;
278
287
  }
288
+ if (error.code === "EADDRINUSE") {
289
+ const hint = murder
290
+ ? `port ${port} is still in use after --murder`
291
+ : `port ${port} is already in use. Pass --murder to kill it and start, or set PORT to another value.`;
292
+ const wrapped = new Error(hint);
293
+ /** @type {NodeJS.ErrnoException} */ (wrapped).code = "EADDRINUSE";
294
+ reject(wrapped);
295
+ return;
296
+ }
279
297
  reject(error);
280
298
  });
281
299
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Legacy EJS şablon motoru — opsiyonel peer.
3
+ *
4
+ * `.jsk`-only uygulamalar `ejs` kurmadan çalışır. Bir `.ejs` view veya layout
5
+ * istendiğinde bu modül uygulamadan (yoksa framework'ten) `ejs` yükler;
6
+ * paket yoksa göç yolunu gösteren net bir hata verir.
7
+ */
8
+ import { createRequire } from "node:module";
9
+ import { pathToFileURL } from "node:url";
10
+ import path from "node:path";
11
+ import { getConfig, FRAMEWORK_ROOT } from "../config/index.js";
12
+ import { tryImportFromApp } from "../build/resolve-peer.mjs";
13
+
14
+ /** @type {import('ejs') | null | undefined} */
15
+ let cached;
16
+
17
+ const MISSING =
18
+ "EJS templates require the optional peer dependency `ejs`. " +
19
+ "Install it (`npm i ejs`) or migrate the template to `.jsk`.";
20
+
21
+ /**
22
+ * @returns {Promise<import('ejs')>}
23
+ */
24
+ export async function loadEjs() {
25
+ if (cached) return cached;
26
+ if (cached === null) throw new Error(MISSING);
27
+
28
+ const config = getConfig();
29
+ let mod = await tryImportFromApp(config.root, "ejs");
30
+
31
+ // Framework kendi test/dev ortamında peer olarak tutuyor olabilir.
32
+ if (!mod) {
33
+ try {
34
+ const require = createRequire(path.join(FRAMEWORK_ROOT, "package.json"));
35
+ mod = await import(pathToFileURL(require.resolve("ejs")).href);
36
+ } catch {
37
+ mod = null;
38
+ }
39
+ }
40
+
41
+ if (!mod) {
42
+ cached = null;
43
+ throw new Error(MISSING);
44
+ }
45
+
46
+ cached = /** @type {import('ejs')} */ (mod.default ?? mod);
47
+ return cached;
48
+ }
49
+
50
+ /**
51
+ * @param {string} file
52
+ * @param {Record<string, unknown>} data
53
+ * @param {object} options
54
+ * @returns {Promise<string>}
55
+ */
56
+ export async function renderEjsFile(file, data, options) {
57
+ const ejs = await loadEjs();
58
+ return ejs.renderFile(file, data, options);
59
+ }
@@ -8,8 +8,13 @@
8
8
  *
9
9
  * sharp yoksa 302 ile orijinale yönlendirilir — sayfa bozulmaz, tasarruf
10
10
  * olmaz. Deployment notu: remote açıksa sharp runtime bağımlılığıdır.
11
+ *
12
+ * Fetch redirect'leri elle takip edilir: her hop allowlist + blocked-address
13
+ * (ve mümkünse DNS çözümü) ile yeniden doğrulanır — açık redirect SSRF'sini
14
+ * kapatmak için `redirect: "follow"` kullanılmaz.
11
15
  */
12
16
  import crypto from "node:crypto";
17
+ import dns from "node:dns/promises";
13
18
  import fs from "node:fs";
14
19
  import path from "node:path";
15
20
  import process from "node:process";
@@ -22,6 +27,8 @@ let sharpModule;
22
27
  /** @type {string | null} */
23
28
  let cacheDir = null;
24
29
 
30
+ const MAX_REDIRECTS = 5;
31
+
25
32
  /**
26
33
  * @returns {import('../config/index.js').ImagesRemoteConfig | null}
27
34
  */
@@ -49,13 +56,45 @@ export function parseAllowedRemoteUrl(src) {
49
56
  return null;
50
57
  }
51
58
 
52
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
53
- if (!isHostAllowed(url.hostname, remote.allowHosts)) return null;
54
- if (isBlockedAddress(url.hostname)) return null;
55
-
59
+ if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return null;
56
60
  return url;
57
61
  }
58
62
 
63
+ /**
64
+ * Host allowlist + literal private IP; redirect hop'larında da kullanılır.
65
+ *
66
+ * @param {URL} url
67
+ * @param {string[]} allowHosts
68
+ * @returns {boolean}
69
+ */
70
+ export function isRemoteUrlShapeAllowed(url, allowHosts) {
71
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
72
+ if (!isHostAllowed(url.hostname, allowHosts)) return false;
73
+ if (isBlockedAddress(url.hostname)) return false;
74
+ return true;
75
+ }
76
+
77
+ /**
78
+ * Hostname'i çözümleyip private IP'ye düşüyorsa reddet (DNS rebinding
79
+ * savunması; TOCTOU kalır ama check-time private resolve yakalanır).
80
+ *
81
+ * @param {string} hostname
82
+ * @returns {Promise<boolean>} true = güvenli
83
+ */
84
+ export async function assertResolvedHostSafe(hostname) {
85
+ if (isBlockedAddress(hostname)) return false;
86
+ try {
87
+ const results = await dns.lookup(hostname, { all: true, verbatim: true });
88
+ if (!results.length) return false;
89
+ for (const { address } of results) {
90
+ if (isBlockedAddress(address)) return false;
91
+ }
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
59
98
  /**
60
99
  * @param {string} hostname
61
100
  * @param {string[]} allowHosts
@@ -257,6 +296,8 @@ async function handleOptimize(req, res, remote) {
257
296
  }
258
297
 
259
298
  /**
299
+ * Redirect'leri elle takip eder; her hop allowlist + DNS private kontrolünden geçer.
300
+ *
260
301
  * @param {string} href
261
302
  * @param {import('../config/index.js').ImagesRemoteConfig} remote
262
303
  * @returns {Promise<{ ok: true, buffer: Buffer } | { ok: false }>}
@@ -266,30 +307,57 @@ async function fetchUpstream(href, remote) {
266
307
  const timer = setTimeout(() => controller.abort(), remote.fetchTimeoutMs);
267
308
 
268
309
  try {
269
- const response = await fetch(href, {
270
- signal: controller.signal,
271
- redirect: "follow",
272
- headers: {
273
- // Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
274
- Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
275
- "User-Agent": "jskelet-image-optimizer/1",
276
- },
277
- });
278
-
279
- if (!response.ok) return { ok: false };
280
-
281
- const type = response.headers.get("content-type") ?? "";
282
- if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
283
- return { ok: false };
310
+ let current = href;
311
+
312
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
313
+ let url;
314
+ try {
315
+ url = new URL(current);
316
+ } catch {
317
+ return { ok: false };
318
+ }
319
+
320
+ if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return { ok: false };
321
+ if (!(await assertResolvedHostSafe(url.hostname))) return { ok: false };
322
+
323
+ const response = await fetch(url.href, {
324
+ signal: controller.signal,
325
+ redirect: "manual",
326
+ headers: {
327
+ // Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
328
+ Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
329
+ "User-Agent": "jskelet-image-optimizer/1",
330
+ },
331
+ });
332
+
333
+ if (response.status >= 300 && response.status < 400) {
334
+ const location = response.headers.get("location");
335
+ if (!location) return { ok: false };
336
+ try {
337
+ current = new URL(location, url).href;
338
+ } catch {
339
+ return { ok: false };
340
+ }
341
+ continue;
342
+ }
343
+
344
+ if (!response.ok) return { ok: false };
345
+
346
+ const type = response.headers.get("content-type") ?? "";
347
+ if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
348
+ return { ok: false };
349
+ }
350
+
351
+ const length = Number(response.headers.get("content-length") ?? 0);
352
+ if (length > remote.maxBytes) return { ok: false };
353
+
354
+ const buffer = Buffer.from(await response.arrayBuffer());
355
+ if (buffer.byteLength > remote.maxBytes) return { ok: false };
356
+
357
+ return { ok: true, buffer };
284
358
  }
285
359
 
286
- const length = Number(response.headers.get("content-length") ?? 0);
287
- if (length > remote.maxBytes) return { ok: false };
288
-
289
- const buffer = Buffer.from(await response.arrayBuffer());
290
- if (buffer.byteLength > remote.maxBytes) return { ok: false };
291
-
292
- return { ok: true, buffer };
360
+ return { ok: false };
293
361
  } catch {
294
362
  return { ok: false };
295
363
  } finally {