jskelet 0.5.3 → 0.5.5

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.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Alt alan adları arasında paylaşılan cookie yazma.
3
+ *
4
+ * `Secure` protokole bakılır (`https` / `x-forwarded-proto`), `NODE_ENV`'e değil —
5
+ * yerel https veya prod http yanlış bayrak üretmesin.
6
+ *
7
+ * Domain `brand.sharedCookieRoots` ile seçilir. Eşleşme yoksa ya da değer çok
8
+ * büyükse `{ ok: false, handoff: true }` döner; çağıran handoff yoluna düşer.
9
+ *
10
+ * JWT'yi paylaşımlı cookie'ye koymayın: kısa session id koyun.
11
+ */
12
+ import { getConfig } from "../config/index.js";
13
+ import {
14
+ SHARED_COOKIE_WARN_BYTES,
15
+ resolveSharedCookieDomain,
16
+ stripHostPort,
17
+ } from "../shared/cookie-domain.js";
18
+ import { clearCookie, setCookie } from "./cookies.js";
19
+
20
+ export {
21
+ SHARED_COOKIE_WARN_BYTES,
22
+ resolveSharedCookieDomain,
23
+ stripHostPort,
24
+ } from "../shared/cookie-domain.js";
25
+
26
+ /**
27
+ * @param {{ secure?: boolean, protocol?: string,
28
+ * headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
29
+ * @returns {boolean}
30
+ */
31
+ export function requestIsHttps(req) {
32
+ if (!req) return false;
33
+ if (req.secure === true) return true;
34
+ if (typeof req.protocol === "string" && req.protocol === "https") return true;
35
+
36
+ const forwarded =
37
+ req.get?.("x-forwarded-proto") ??
38
+ (typeof req.headers?.["x-forwarded-proto"] === "string"
39
+ ? req.headers["x-forwarded-proto"]
40
+ : Array.isArray(req.headers?.["x-forwarded-proto"])
41
+ ? req.headers["x-forwarded-proto"][0]
42
+ : "");
43
+ const first = String(forwarded ?? "")
44
+ .split(",")[0]
45
+ .trim()
46
+ .toLowerCase();
47
+ return first === "https";
48
+ }
49
+
50
+ /**
51
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined,
52
+ * hostname?: string }} [req]
53
+ * @returns {string}
54
+ */
55
+ function hostFromReq(req) {
56
+ if (!req) return "";
57
+ const forwarded =
58
+ req.get?.("x-forwarded-host") ??
59
+ (typeof req.headers?.["x-forwarded-host"] === "string"
60
+ ? req.headers["x-forwarded-host"]
61
+ : "");
62
+ const raw =
63
+ forwarded ||
64
+ req.get?.("host") ||
65
+ (typeof req.headers?.host === "string" ? req.headers.host : "") ||
66
+ (typeof req.hostname === "string" ? req.hostname : "");
67
+ return stripHostPort(String(raw).split(",")[0]);
68
+ }
69
+
70
+ /**
71
+ * @returns {string[]}
72
+ */
73
+ function configuredRoots() {
74
+ try {
75
+ const roots = getConfig().brand?.sharedCookieRoots;
76
+ return Array.isArray(roots) ? roots.map(String) : [];
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @typedef {import('./cookies.js').CookieOptions} CookieOptions
84
+ *
85
+ * @typedef {object} SharedCookieResult
86
+ * @property {boolean} ok Domain seçildi ve Set-Cookie yazıldı.
87
+ * @property {string | null} domain Yazılan Domain ya da null.
88
+ * @property {boolean} handoff ok değilse çağıran handoff denemeli.
89
+ * @property {string} [reason] Teşhis.
90
+ */
91
+
92
+ /**
93
+ * Paylaşımlı Domain ile cookie yazar.
94
+ *
95
+ * @param {import('http').ServerResponse & { req?: import('http').IncomingMessage }} res
96
+ * @param {string} name
97
+ * @param {string} value Kısa session id — JWT değil.
98
+ * @param {CookieOptions & { req?: import('http').IncomingMessage, roots?: string[],
99
+ * host?: string }} [options]
100
+ * @returns {SharedCookieResult}
101
+ */
102
+ export function writeSharedCookie(res, name, value, options = {}) {
103
+ const { req: optReq, roots: optRoots, host: optHost, ...cookieOpts } = options;
104
+ const req = optReq ?? /** @type {{ req?: import('http').IncomingMessage }} */ (res).req;
105
+ const roots = optRoots ?? configuredRoots();
106
+ const host = optHost ? stripHostPort(optHost) : hostFromReq(req);
107
+ const domain = resolveSharedCookieDomain(host, roots);
108
+
109
+ if (!domain) {
110
+ return {
111
+ ok: false,
112
+ domain: null,
113
+ handoff: true,
114
+ reason: "no-matching-root",
115
+ };
116
+ }
117
+
118
+ const text = String(value ?? "");
119
+ if (Buffer.byteLength(text, "utf8") > SHARED_COOKIE_WARN_BYTES) {
120
+ console.warn(
121
+ `[cookies] shared cookie "${name}" is ${Buffer.byteLength(text, "utf8")} bytes; ` +
122
+ `prefer a short session id (≤${SHARED_COOKIE_WARN_BYTES}). Large tokens do not belong in shared cookies.`,
123
+ );
124
+ return {
125
+ ok: false,
126
+ domain,
127
+ handoff: true,
128
+ reason: "value-too-large",
129
+ };
130
+ }
131
+
132
+ const secure = cookieOpts.secure ?? requestIsHttps(req);
133
+
134
+ setCookie(res, name, text, {
135
+ ...cookieOpts,
136
+ domain,
137
+ secure,
138
+ // Paylaşımlı oturum kimliği JS'ye gerekmez; HttpOnly kalsın.
139
+ httpOnly: cookieOpts.httpOnly !== false,
140
+ sameSite: cookieOpts.sameSite ?? "Lax",
141
+ path: cookieOpts.path ?? "/",
142
+ });
143
+
144
+ // Sunucuda document.cookie read-back yok; Domain seçildiyse yazım başarılı sayılır.
145
+ // İstemci `writeSharedCookie` read-back yapar; başarısızsa handoff'a düşer.
146
+ return { ok: true, domain, handoff: false };
147
+ }
148
+
149
+ /**
150
+ * Aynı Domain ile cookie siler.
151
+ *
152
+ * @param {import('http').ServerResponse & { req?: import('http').IncomingMessage }} res
153
+ * @param {string} name
154
+ * @param {CookieOptions & { req?: import('http').IncomingMessage, roots?: string[],
155
+ * host?: string }} [options]
156
+ * @returns {SharedCookieResult}
157
+ */
158
+ export function clearSharedCookie(res, name, options = {}) {
159
+ const { req: optReq, roots: optRoots, host: optHost, ...cookieOpts } = options;
160
+ const req = optReq ?? /** @type {{ req?: import('http').IncomingMessage }} */ (res).req;
161
+ const roots = optRoots ?? configuredRoots();
162
+ const host = optHost ? stripHostPort(optHost) : hostFromReq(req);
163
+ const domain = resolveSharedCookieDomain(host, roots);
164
+
165
+ if (!domain) {
166
+ return { ok: false, domain: null, handoff: false, reason: "no-matching-root" };
167
+ }
168
+
169
+ const secure = cookieOpts.secure ?? requestIsHttps(req);
170
+ clearCookie(res, name, {
171
+ ...cookieOpts,
172
+ domain,
173
+ secure,
174
+ path: cookieOpts.path ?? "/",
175
+ });
176
+
177
+ return { ok: true, domain, handoff: false };
178
+ }
package/src/index.js CHANGED
@@ -35,6 +35,13 @@ export {
35
35
  setCookie,
36
36
  setSignedCookie,
37
37
  } from "./http/cookies.js";
38
+ export {
39
+ clearSharedCookie,
40
+ requestIsHttps,
41
+ resolveSharedCookieDomain,
42
+ SHARED_COOKIE_WARN_BYTES,
43
+ writeSharedCookie,
44
+ } from "./http/shared-cookie.js";
38
45
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
39
46
  export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
40
47
  export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Alt alan adı handoff: kısa ömürlü tek kullanımlık bilet.
3
+ *
4
+ * Akış:
5
+ * 1. Paylaşımlı cookie yazımı başarısız (istemci read-back fail)
6
+ * 2. POST /_jskelet/auth/handoff { name, value, next }
7
+ * 3. Yanıt { url: "https://tr.…/path?handoff=TICKET" }
8
+ * 4. Hedef host'ta GET middleware bileti tüketir → Set-Cookie + 303
9
+ *
10
+ * Bilet süreç belleğinde; tek süreçte tüm locale host'ları servis eden
11
+ * kurulumlar için yeterli. Değer kısa session id olmalı.
12
+ */
13
+ import { getConfig } from "../../config/index.js";
14
+ import { randomToken, setCookie } from "../../http/cookies.js";
15
+ import {
16
+ SHARED_COOKIE_WARN_BYTES,
17
+ requestIsHttps,
18
+ writeSharedCookie,
19
+ } from "../../http/shared-cookie.js";
20
+ import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
21
+
22
+ /** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
23
+ const tickets = new Map();
24
+
25
+ const DEFAULT_TTL_SECONDS = 60;
26
+ const HANDOFF_PATH = "/_jskelet/auth/handoff";
27
+
28
+ /**
29
+ * @returns {{ enabled: boolean, ttlSeconds: number, path: string, maxValueBytes: number }}
30
+ */
31
+ function settings() {
32
+ const auth = getConfig().auth ?? {};
33
+ const raw = auth.crossSubdomainHandoff;
34
+ if (raw === false || raw == null) {
35
+ return {
36
+ enabled: false,
37
+ ttlSeconds: DEFAULT_TTL_SECONDS,
38
+ path: HANDOFF_PATH,
39
+ maxValueBytes: SHARED_COOKIE_WARN_BYTES,
40
+ };
41
+ }
42
+
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
+ }
51
+
52
+ const source = /** @type {Record<string, unknown>} */ (raw);
53
+ const ttl = Number(source.ttlSeconds);
54
+ const maxBytes = Number(source.maxValueBytes);
55
+ return {
56
+ enabled: source.enabled !== false,
57
+ ttlSeconds:
58
+ Number.isFinite(ttl) && ttl > 0 ? Math.min(300, Math.floor(ttl)) : DEFAULT_TTL_SECONDS,
59
+ path:
60
+ typeof source.path === "string" && source.path.startsWith("/")
61
+ ? source.path
62
+ : HANDOFF_PATH,
63
+ maxValueBytes:
64
+ Number.isFinite(maxBytes) && maxBytes > 0
65
+ ? Math.floor(maxBytes)
66
+ : SHARED_COOKIE_WARN_BYTES,
67
+ };
68
+ }
69
+
70
+ function sweep() {
71
+ const now = Date.now();
72
+ for (const [id, entry] of tickets) {
73
+ if (entry.exp <= now) tickets.delete(id);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * @param {string} next
79
+ * @param {import('express').Request} req
80
+ * @returns {URL | null}
81
+ */
82
+ function parseNext(next, req) {
83
+ try {
84
+ return new URL(next, `${req.protocol}://${req.get("host")}`);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * @param {URL} url
92
+ * @param {import('express').Request} req
93
+ * @returns {boolean}
94
+ */
95
+ function nextAllowed(url, req) {
96
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
97
+
98
+ const roots = getConfig().brand?.sharedCookieRoots ?? [];
99
+ const targetHost = url.hostname.toLowerCase();
100
+ const sourceHost = String(req.hostname || "")
101
+ .toLowerCase()
102
+ .replace(/:\d+$/, "");
103
+
104
+ if (targetHost === sourceHost) return true;
105
+
106
+ const sourceRoot = resolveSharedCookieDomain(sourceHost, roots);
107
+ const targetRoot = resolveSharedCookieDomain(targetHost, roots);
108
+ return Boolean(sourceRoot && targetRoot && sourceRoot === targetRoot);
109
+ }
110
+
111
+ /**
112
+ * @param {import('express').Request} req
113
+ * @param {import('express').Response} res
114
+ * @param {{ name: string, value: string, maxAge?: number }} payload
115
+ * @returns {void}
116
+ */
117
+ function issueCookie(req, res, payload) {
118
+ const shared = writeSharedCookie(res, payload.name, payload.value, {
119
+ req,
120
+ maxAge: payload.maxAge,
121
+ });
122
+
123
+ if (shared.ok) return;
124
+
125
+ setCookie(res, payload.name, payload.value, {
126
+ path: "/",
127
+ httpOnly: true,
128
+ sameSite: "Lax",
129
+ secure: requestIsHttps(req),
130
+ maxAge: payload.maxAge,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * @param {string} ticketId
136
+ * @returns {{ name: string, value: string, maxAge?: number } | null}
137
+ */
138
+ function redeem(ticketId) {
139
+ sweep();
140
+ const entry = tickets.get(ticketId);
141
+ if (!entry) return null;
142
+ tickets.delete(ticketId);
143
+ if (entry.exp <= Date.now()) return null;
144
+ return { name: entry.name, value: entry.value, maxAge: entry.maxAge };
145
+ }
146
+
147
+ /**
148
+ * Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
149
+ *
150
+ * @param {import('express').Express} app
151
+ * @returns {void}
152
+ */
153
+ export function mountAuthHandoff(app) {
154
+ const cfg = settings();
155
+ if (!cfg.enabled) return;
156
+
157
+ app.post(cfg.path, (req, res) => {
158
+ sweep();
159
+
160
+ const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
161
+ const value = typeof req.body?.value === "string" ? req.body.value : "";
162
+ const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
163
+ const maxAge =
164
+ req.body?.maxAge != null && Number.isFinite(Number(req.body.maxAge))
165
+ ? Math.floor(Number(req.body.maxAge))
166
+ : undefined;
167
+
168
+ if (!name || !next) {
169
+ res.status(400).json({ ok: false, error: "name and next are required" });
170
+ return;
171
+ }
172
+
173
+ if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
174
+ res.status(400).json({
175
+ ok: false,
176
+ error: `value exceeds ${cfg.maxValueBytes} bytes; use a short session id`,
177
+ });
178
+ return;
179
+ }
180
+
181
+ const nextUrl = parseNext(next, req);
182
+ if (!nextUrl || !nextAllowed(nextUrl, req)) {
183
+ res.status(400).json({ ok: false, error: "next is not an allowed URL" });
184
+ return;
185
+ }
186
+
187
+ const id = randomToken(24);
188
+ tickets.set(id, {
189
+ name,
190
+ value,
191
+ maxAge,
192
+ exp: Date.now() + cfg.ttlSeconds * 1000,
193
+ });
194
+
195
+ nextUrl.searchParams.set("handoff", id);
196
+ res.setHeader("Cache-Control", "no-store");
197
+ res.json({ ok: true, url: nextUrl.href });
198
+ });
199
+
200
+ // Hedef host'ta `?handoff=` yakala — route render'ından önce.
201
+ app.use((req, res, next) => {
202
+ if (req.method !== "GET" && req.method !== "HEAD") return next();
203
+ const ticket = typeof req.query?.handoff === "string" ? req.query.handoff : "";
204
+ if (!ticket) return next();
205
+
206
+ const payload = redeem(ticket);
207
+ if (!payload) return next();
208
+
209
+ issueCookie(req, res, payload);
210
+
211
+ const url = new URL(req.originalUrl || req.url, `${req.protocol}://${req.get("host")}`);
212
+ url.searchParams.delete("handoff");
213
+ res.setHeader("Cache-Control", "no-store");
214
+ res.redirect(303, url.pathname + url.search + url.hash);
215
+ });
216
+ }
217
+
218
+ /** @returns {void} */
219
+ export function _resetHandoffTickets() {
220
+ tickets.clear();
221
+ }
222
+
223
+ /** @returns {number} */
224
+ export function _handoffTicketCount() {
225
+ return tickets.size;
226
+ }
@@ -21,6 +21,9 @@
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ı.
24
27
  * 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
25
28
  * Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
26
29
  * 7. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
@@ -149,6 +152,17 @@ export async function createApp(options = {}) {
149
152
  app.use(express.urlencoded({ extended: false, limit: "64kb" }));
150
153
  app.use(express.json({ limit: "256kb" }));
151
154
 
155
+ const handoff = config.auth?.crossSubdomainHandoff;
156
+ if (
157
+ handoff === true ||
158
+ (handoff &&
159
+ typeof handoff === "object" &&
160
+ /** @type {{ enabled?: boolean }} */ (handoff).enabled !== false)
161
+ ) {
162
+ const { mountAuthHandoff } = await import("./auth/handoff.js");
163
+ mountAuthHandoff(app);
164
+ }
165
+
152
166
  app.use(csrf());
153
167
 
154
168
  app.use(configRewrites("afterFiles"));
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Paylaşımlı cookie Domain seçimi — sunucu ve istemci aynı kuralları kullanır.
3
+ *
4
+ * `brand.sharedCookieRoots` örn. `[".investvio.com", ".localhost"]`.
5
+ * Host bu köklerden birine uyuyorsa Domain olarak o kök yazılır.
6
+ */
7
+
8
+ /**
9
+ * @param {string} host Host veya Host:port.
10
+ * @returns {string} Lowercase, portsuz.
11
+ */
12
+ export function stripHostPort(host) {
13
+ const raw = String(host ?? "")
14
+ .split(",")[0]
15
+ .trim()
16
+ .toLowerCase();
17
+ if (!raw) return "";
18
+ if (raw.startsWith("[")) {
19
+ const end = raw.indexOf("]");
20
+ return end === -1 ? raw : raw.slice(0, end + 1);
21
+ }
22
+ const colon = raw.lastIndexOf(":");
23
+ if (colon === -1) return raw;
24
+ if (raw.indexOf(":") !== colon) return raw;
25
+ return raw.slice(0, colon);
26
+ }
27
+
28
+ /**
29
+ * Kökü `Domain=` biçimine getirir (başında nokta).
30
+ *
31
+ * @param {unknown} root
32
+ * @returns {string | null}
33
+ */
34
+ export function normalizeCookieRoot(root) {
35
+ if (typeof root !== "string") return null;
36
+ const trimmed = root.trim().toLowerCase();
37
+ if (!trimmed || trimmed === ".") return null;
38
+ const bare = trimmed.replace(/^\./, "");
39
+ if (!bare || bare.includes("/") || bare.includes(":")) return null;
40
+ return `.${bare}`;
41
+ }
42
+
43
+ /**
44
+ * @param {string} hostname Portsuz hostname.
45
+ * @param {Iterable<string>} roots `brand.sharedCookieRoots`.
46
+ * @returns {string | null} `Domain` değeri (başında nokta) ya da eşleşme yoksa null.
47
+ */
48
+ export function resolveSharedCookieDomain(hostname, roots) {
49
+ const host = stripHostPort(hostname);
50
+ if (!host) return null;
51
+
52
+ for (const entry of roots ?? []) {
53
+ const root = normalizeCookieRoot(entry);
54
+ if (!root) continue;
55
+ const bare = root.slice(1);
56
+ if (host === bare || host.endsWith(`.${bare}`)) return root;
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Paylaşımlı cookie için önerilen üst sınır. JWT / büyük token buraya
64
+ * sığmaz — kısa session id koyun; aksi halde handoff veya host-only cookie.
65
+ */
66
+ export const SHARED_COOKIE_WARN_BYTES = 512;