@zvndev/darkmown 2.10.0 → 2.10.1

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/src/api-runner.js CHANGED
@@ -78,13 +78,17 @@ function dynamicCount(segments) {
78
78
  /**
79
79
  * Match a request pathname against discovered routes. The pathname is expected
80
80
  * to start with `/api`; anything else returns `null` (caller serves static).
81
+ * A dynamic segment whose percent-encoding is malformed (`%E0%A4%A`) makes
82
+ * `decodeURIComponent` throw — that is reported as `{ malformed: true }` so the
83
+ * caller answers a clean 400 instead of crashing the request handler.
81
84
  * @param {ApiRoute[]} routes
82
85
  * @param {string} pathname Request pathname (no query string).
83
- * @returns {{ file: string, params: Record<string, string> } | null}
86
+ * @returns {{ file: string, params: Record<string, string> } | { malformed: true } | null}
84
87
  */
85
88
  export function matchApiRoute(routes, pathname) {
86
89
  if (pathname !== "/api" && !pathname.startsWith("/api/")) return null;
87
90
  const parts = pathname.split("/").filter(Boolean).slice(1); // drop leading "api"
91
+ let sawMalformed = false;
88
92
  for (const route of routes) {
89
93
  if (route.segments.length !== parts.length) continue;
90
94
  /** @type {Record<string, string>} */
@@ -93,7 +97,15 @@ export function matchApiRoute(routes, pathname) {
93
97
  for (let i = 0; i < parts.length; i++) {
94
98
  const seg = route.segments[i];
95
99
  if (seg.startsWith("[") && seg.endsWith("]")) {
96
- params[seg.slice(1, -1)] = decodeURIComponent(parts[i]);
100
+ try {
101
+ params[seg.slice(1, -1)] = decodeURIComponent(parts[i]);
102
+ } catch {
103
+ // Malformed here does not preclude a static match on a later route,
104
+ // so keep scanning and report only if nothing else claimed the path.
105
+ sawMalformed = true;
106
+ ok = false;
107
+ break;
108
+ }
97
109
  } else if (seg !== parts[i]) {
98
110
  ok = false;
99
111
  break;
@@ -101,13 +113,15 @@ export function matchApiRoute(routes, pathname) {
101
113
  }
102
114
  if (ok) return { file: route.file, params };
103
115
  }
104
- return null;
116
+ return sawMalformed ? { malformed: true } : null;
105
117
  }
106
118
 
107
119
  /**
108
120
  * Build a Web-standard `Request` from a Node `IncomingMessage`. The body (for
109
- * methods that carry one) is buffered fully — dev endpoints are small and this
110
- * avoids the `duplex: "half"` streaming dance.
121
+ * methods that carry one) is buffered up to {@link MAX_BODY_BYTES} — dev and
122
+ * preview endpoints are small and this avoids the `duplex: "half"` streaming
123
+ * dance. A larger body rejects with {@link BodyTooLargeError}, which
124
+ * `handleApiRequest` answers as a 413.
111
125
  * @param {import("node:http").IncomingMessage} req
112
126
  * @returns {Promise<Request>}
113
127
  */
@@ -125,6 +139,15 @@ export async function nodeRequestToWeb(req) {
125
139
  return new Request(url, init);
126
140
  }
127
141
 
142
+ /** Bodies larger than this are rejected with a 413 rather than buffered without bound. */
143
+ const MAX_BODY_BYTES = 10 * 1024 * 1024;
144
+
145
+ /**
146
+ * Rejection marker for an over-cap request body, so `handleApiRequest` answers
147
+ * 413 (a client error) instead of a generic 500.
148
+ */
149
+ class BodyTooLargeError extends Error {}
150
+
128
151
  /**
129
152
  * @param {import("node:http").IncomingMessage} req
130
153
  * @returns {Promise<Buffer>}
@@ -133,7 +156,22 @@ function readBody(req) {
133
156
  return new Promise((resolve, reject) => {
134
157
  /** @type {Buffer[]} */
135
158
  const chunks = [];
136
- req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
159
+ let received = 0;
160
+ let over = false;
161
+ req.on("data", (chunk) => {
162
+ received += chunk.length;
163
+ if (received > MAX_BODY_BYTES) {
164
+ // Stop accumulating, but keep draining the socket: destroying the
165
+ // request here would kill the connection before the 413 answer can
166
+ // reach the client (verified: `req.destroy()` mid-upload → EPIPE).
167
+ if (!over) {
168
+ over = true;
169
+ reject(new BodyTooLargeError(`Request body too large (over ${MAX_BODY_BYTES} bytes)`));
170
+ }
171
+ return;
172
+ }
173
+ chunks.push(Buffer.from(chunk));
174
+ });
137
175
  req.on("end", () => resolve(Buffer.concat(chunks)));
138
176
  req.on("error", reject);
139
177
  });
@@ -189,18 +227,41 @@ export async function writeWebResponse(response, res, acceptEncoding) {
189
227
  * the handler succeeded or threw), `false` when nothing matched so the caller
190
228
  * should fall through to static serving.
191
229
  *
230
+ * Error disclosure: a handler throw is answered as JSON either way, but the
231
+ * `error` field carries the real message only when `discloseErrors` is set
232
+ * (the dev server's default — a documented dev convenience). The preview
233
+ * server passes `false` and the client gets the generic "Internal error",
234
+ * matching the shape the generated Cloudflare worker ships.
235
+ *
192
236
  * @param {object} io
193
237
  * @param {string} io.apiDir Absolute path to the project's `api/` directory.
194
238
  * @param {import("node:http").IncomingMessage} io.req
195
239
  * @param {import("node:http").ServerResponse} io.res
196
240
  * @param {string | string[] | undefined} [io.acceptEncoding] The request's
197
241
  * Accept-Encoding header, when the caller negotiates compression (preview).
242
+ * @param {boolean} [io.discloseErrors] Echo the thrown message to the client
243
+ * (default true, the dev behavior). Pass false on any production-shaped path.
198
244
  * @returns {Promise<boolean>}
199
245
  */
200
- export async function handleApiRequest({ apiDir, req, res, acceptEncoding }) {
246
+ export async function handleApiRequest({
247
+ apiDir,
248
+ req,
249
+ res,
250
+ acceptEncoding,
251
+ discloseErrors = true
252
+ }) {
201
253
  const pathname = new URL(req.url || "/", "http://localhost").pathname;
202
254
  const match = matchApiRoute(discoverApiRoutes(apiDir), pathname);
203
255
  if (!match) return false;
256
+ if ("malformed" in match) {
257
+ // A malformed percent-encoded dynamic segment: the URL itself is at fault,
258
+ // so this is a 400, not a 500 — and it must never reach the caller's catch.
259
+ await writeWebResponse(
260
+ Response.json({ ok: false, error: "Malformed URL encoding" }, { status: 400 }),
261
+ res
262
+ );
263
+ return true;
264
+ }
204
265
 
205
266
  try {
206
267
  const handler = await importHandler(match.file);
@@ -215,7 +276,18 @@ export async function handleApiRequest({ apiDir, req, res, acceptEncoding }) {
215
276
  }
216
277
  await writeWebResponse(response, res, acceptEncoding);
217
278
  } catch (err) {
218
- const message = err instanceof Error ? err.message : String(err);
279
+ if (err instanceof BodyTooLargeError) {
280
+ await writeWebResponse(
281
+ Response.json({ ok: false, error: "Request body too large" }, { status: 413 }),
282
+ res
283
+ );
284
+ return true;
285
+ }
286
+ const message = discloseErrors
287
+ ? err instanceof Error
288
+ ? err.message
289
+ : String(err)
290
+ : "Internal error";
219
291
  await writeWebResponse(Response.json({ ok: false, error: message }, { status: 500 }), res);
220
292
  }
221
293
  return true;
package/src/builder.js CHANGED
@@ -1464,15 +1464,17 @@ function emitAssets(assets, paths, pageHtml, warned) {
1464
1464
  fs.mkdirSync(path.dirname(out), { recursive: true });
1465
1465
  if (source.endsWith(".skin")) {
1466
1466
  const src = fs.readFileSync(source, "utf8");
1467
+ // `file` threads the source path into compileSkin so a skin authoring
1468
+ // error reports `file:line` like every other compile error.
1467
1469
  if (assets.scopedSkins.has(source)) {
1468
1470
  const rel = path.relative(paths.cwd, source).replaceAll(path.sep, "/");
1469
1471
  const scope = scopeIdFor(rel);
1470
1472
  /** @type {Set<string>} */
1471
1473
  const subjects = new Set();
1472
- fs.writeFileSync(out, compileSkin(src, { scope, subjects }));
1474
+ fs.writeFileSync(out, compileSkin(src, { scope, subjects, file: source }));
1473
1475
  warnUnusedScoped(subjects, scope, pageHtml, path.basename(source), warned);
1474
1476
  } else {
1475
- fs.writeFileSync(out, compileSkin(src));
1477
+ fs.writeFileSync(out, compileSkin(src, { file: source }));
1476
1478
  }
1477
1479
  } else {
1478
1480
  fs.copyFileSync(source, out);
package/src/cli.js CHANGED
@@ -413,7 +413,10 @@ function startDevServer({ cwd, log, warn, error }) {
413
413
  }
414
414
  // Serverless parity: run a project `api/*.js` function if the path matches,
415
415
  // exactly as Vercel/Cloudflare would in production; otherwise serve static. A
416
- // throw on either path (e.g. a stat/read error) becomes a clean 500.
416
+ // throw on either path (e.g. a stat/read error) becomes a clean 500. Dev
417
+ // DELIBERATELY echoes the stack and the api runner keeps real error messages
418
+ // (the `discloseErrors` default) — this is the author's own machine; `serve`
419
+ // is the production-shaped path and discloses nothing.
417
420
  handleApiRequest({ apiDir, req, res })
418
421
  .then((handled) => {
419
422
  if (!handled) serveDev(distRoot, url, res, draftRoutes(cwd), () => lastBuildError);
@@ -473,15 +476,20 @@ function startPreviewServer({ cwd, log, error }) {
473
476
  const server = http.createServer((req, res) => {
474
477
  const acceptEncoding = req.headers["accept-encoding"];
475
478
  // Serverless parity, exactly as in dev: run a project api/*.js function when
476
- // the path matches; otherwise serve static (compressed when negotiated). A
477
- // throw on either path (e.g. a malformed dynamic segment) becomes a clean 500.
478
- handleApiRequest({ apiDir, req, res, acceptEncoding })
479
+ // the path matches; otherwise serve static (compressed when negotiated).
480
+ // Unlike dev, serve mirrors PRODUCTION disclosure rules: the api runner gets
481
+ // `discloseErrors: false` (the client sees "Internal error", same shape as
482
+ // the generated Cloudflare worker), a malformed dynamic segment is a clean
483
+ // 400 inside the runner, and an unexpected throw here is logged server-side
484
+ // but never echoed — a stack trace leaks paths and internals to the client.
485
+ handleApiRequest({ apiDir, req, res, acceptEncoding, discloseErrors: false })
479
486
  .then((handled) => {
480
487
  if (!handled) serve(distRoot, req.url || "/", res, { acceptEncoding });
481
488
  })
482
489
  .catch((err) => {
490
+ error(err instanceof Error && err.stack ? err.stack : String(err));
483
491
  res.writeHead(500, { "content-type": "text/plain" });
484
- res.end((err instanceof Error && err.stack) || String(err));
492
+ res.end("Internal Server Error");
485
493
  });
486
494
  });
487
495
  return new Promise((resolve) => {
@@ -174,6 +174,79 @@ function rejectStateSeed(value, raw, directive, ctx, index) {
174
174
  );
175
175
  }
176
176
 
177
+ // ---------------------------------------------------------------------------
178
+ // A SECRET-SHAPED SEED IS A HINT, NEVER AN ERROR.
179
+ //
180
+ // Every `:state`/`:store` seed is stored verbatim and inlined into the public
181
+ // HTML as a JSON script tag — that is the design, and it means a seed that is
182
+ // really an API key or token is published to every reader of the page. The
183
+ // documented pattern is to seed empty and fill from an api/ endpoint at
184
+ // runtime, but nothing stopped an author from pasting the key itself. This
185
+ // check is a heuristic over string seeds (and string values nested in
186
+ // array/object seeds), pushed onto the same `comp.warnings` channel as the
187
+ // other build hints: it can never fail a build, because a false positive must
188
+ // never break one, and it fires at most once per declaration.
189
+ //
190
+ // The shapes are deliberately tight — false positives are the enemy:
191
+ //
192
+ // - Known key prefixes (`sk-`, `ghp_`, `xoxb-`, `AKIA`, …) followed by a real
193
+ // token body, and the `-----BEGIN` private-key header.
194
+ // - A JWT: three base64url segments joined by dots, opening with `eyJ` (the
195
+ // base64 of `{"` — requiring it keeps dotted prose quiet).
196
+ // - A long unbroken token: 32+ chars from the base64/base64url alphabet with
197
+ // no spaces, requiring BOTH cases and a digit. Requiring the case mix keeps
198
+ // kebab-case slugs, hex hashes, and UUIDs (all lowercase) silent, and the
199
+ // charset excludes `:`, `.`, and space, so URLs, file paths, and prose
200
+ // never match.
201
+ // ---------------------------------------------------------------------------
202
+ const SECRET_PREFIX = /^(?:sk-|sk_live_|sk_test_|ghp_|gho_|xoxb-|xoxp-|AKIA)/;
203
+ const SECRET_TOKEN_BODY = /^[A-Za-z0-9_-]{8,}$/;
204
+ const JWT_SHAPE = /^eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/;
205
+ const LONG_TOKEN_SHAPE = /^[A-Za-z0-9/_+=-]{32,}$/;
206
+
207
+ /**
208
+ * Test one string for the secret shapes above.
209
+ * @param {string} text
210
+ * @returns {boolean}
211
+ */
212
+ function looksLikeSecret(text) {
213
+ if (text.startsWith("-----BEGIN")) return true;
214
+ const prefixed = text.match(SECRET_PREFIX);
215
+ if (prefixed && SECRET_TOKEN_BODY.test(text.slice(prefixed[0].length))) return true;
216
+ if (JWT_SHAPE.test(text)) return true;
217
+ return LONG_TOKEN_SHAPE.test(text) && /[a-z]/.test(text) && /[A-Z]/.test(text) && /\d/.test(text);
218
+ }
219
+
220
+ /**
221
+ * Walk a parsed seed for a secret-shaped string: the seed itself, or any
222
+ * string value nested in its arrays/objects.
223
+ * @param {unknown} value
224
+ * @returns {boolean}
225
+ */
226
+ function seedContainsSecret(value) {
227
+ if (typeof value === "string") return looksLikeSecret(value);
228
+ if (Array.isArray(value)) return value.some(seedContainsSecret);
229
+ if (value && typeof value === "object") return Object.values(value).some(seedContainsSecret);
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * Push the secret-seed hint when a `:state`/`:store` seed carries one.
235
+ * @param {unknown} value The parsed seed.
236
+ * @param {string} name The declared name, for the message.
237
+ * @param {string} directive `:state` or `:store`, for the message.
238
+ * @param {Ctx} ctx
239
+ * @param {number} index 0-based line index for `file:line`.
240
+ * @returns {void}
241
+ */
242
+ function warnSecretSeed(value, name, directive, ctx, index) {
243
+ if (!seedContainsSecret(value)) return;
244
+ ctx.comp.warnings.push(
245
+ `${at(ctx, index)}: ${directive} "${name}" seed looks like a secret — it will be inlined into public HTML. ` +
246
+ `Seed empty and fill it from an api/ endpoint at runtime.`
247
+ );
248
+ }
249
+
177
250
  /**
178
251
  * @param {string} line
179
252
  * @param {Ctx} ctx
@@ -195,6 +268,7 @@ export function handleState(line, ctx, index) {
195
268
  const flags = parseModifiers(match[3], ":state", ctx, index);
196
269
  const value = parseStateValue(match[2], at(ctx, index));
197
270
  rejectStateSeed(value, match[2], ":state", ctx, index);
271
+ warnSecretSeed(value, match[1], ":state", ctx, index);
198
272
  const key = declareState(match[1], value, ctx);
199
273
  recordSymbol(ctx, index, { kind: "state", name: key, detail: `${key} = ${match[2].trim()}` });
200
274
  const persistAttr = flags.persist ? ` data-wd-persist="${escapeHtml(key)}"` : "";
@@ -263,6 +337,7 @@ export function handleStore(line, ctx, index) {
263
337
  const flags = parseModifiers(match[3], ":store", ctx, index);
264
338
  const value = parseStateValue(match[2], at(ctx, index));
265
339
  rejectStateSeed(value, match[2], ":store", ctx, index);
340
+ warnSecretSeed(value, match[1], ":store", ctx, index);
266
341
  const name = declareStore(match[1], value, ctx);
267
342
  recordSymbol(ctx, index, { kind: "store", name, detail: `${name} = ${match[2].trim()}` });
268
343
  const ephemeral = flags.ephemeral ? " data-wd-store-ephemeral" : "";
package/src/errors.js CHANGED
@@ -76,6 +76,7 @@ import {
76
76
  IF_EXAMPLE,
77
77
  INCLUDE_EXAMPLE
78
78
  } from "./compiler/structure.js";
79
+ import { FONT_FACE_EXAMPLE, KEYFRAMES_EXAMPLE } from "./skin.js";
79
80
 
80
81
  /**
81
82
  * One subsystem block of the numbering scheme.
@@ -94,8 +95,9 @@ import {
94
95
  * @property {string} title Short name for the mistake.
95
96
  * @property {string} cause What the author wrote that triggers it.
96
97
  * @property {string} fix The corrective action, in one line.
97
- * @property {string} [example] A concrete, compilable line, sourced from the
98
- * compiler's own `*_EXAMPLE` constants.
98
+ * @property {string} [example] A concrete, compilable line (or, for block
99
+ * constructs like the `.skin` at-rules, a minimal compilable block), sourced
100
+ * from the compiler's own `*_EXAMPLE` constants.
99
101
  */
100
102
 
101
103
  /** The subsystem blocks, in code order. */
@@ -1167,6 +1169,36 @@ const ENTRIES = [
1167
1169
  cause: "A top-level `page`/`body`/`html`/`*`/`::selection` selector escapes the scope.",
1168
1170
  fix: "Move page-level styles to a global (non-scoped) skin."
1169
1171
  },
1172
+ {
1173
+ code: "WD804",
1174
+ title: "Malformed `@font-face` block",
1175
+ cause:
1176
+ "`@font-face` was given a name, holds a nested block, or has no descriptors — it takes plain `prop value` declaration lines only.",
1177
+ fix: "Write a bare `@font-face` line and indent each descriptor beneath it.",
1178
+ example: FONT_FACE_EXAMPLE
1179
+ },
1180
+ {
1181
+ code: "WD805",
1182
+ title: "Invalid `@keyframes` name",
1183
+ cause: "The name after `@keyframes` is missing or is not a plain CSS identifier.",
1184
+ fix: "Name the animation with a plain identifier, e.g. `@keyframes fade-in`.",
1185
+ example: KEYFRAMES_EXAMPLE
1186
+ },
1187
+ {
1188
+ code: "WD806",
1189
+ title: "Invalid keyframe selector",
1190
+ cause: "A child of `@keyframes` is not `from`, `to`, or a percentage.",
1191
+ fix: "Use `from`, `to`, a percentage (`50%`), or a comma list of them (`50%, 75%`).",
1192
+ example: KEYFRAMES_EXAMPLE
1193
+ },
1194
+ {
1195
+ code: "WD807",
1196
+ title: "Malformed `@keyframes` body",
1197
+ cause:
1198
+ "A keyframe's declarations nest deeper, a keyframe selector has no declarations, or the block has no keyframe selectors at all.",
1199
+ fix: "Give each keyframe selector a flat list of declarations indented one level beneath it.",
1200
+ example: KEYFRAMES_EXAMPLE
1201
+ },
1170
1202
 
1171
1203
  // --- WD9xx: project, routing, CLI ----------------------------------------
1172
1204
  {
@@ -1211,6 +1243,13 @@ const ENTRIES = [
1211
1243
  cause: "The home page's `ai_crawlers:` is neither `allow` nor `deny`.",
1212
1244
  fix: "Write `ai_crawlers: allow` or `ai_crawlers: deny` (absent means allow)."
1213
1245
  },
1246
+ {
1247
+ code: "WD908",
1248
+ title: "Unsafe route name",
1249
+ cause:
1250
+ "A page filename produced a route containing whitespace or control characters, which would corrupt generated config files (dist/_headers, llms.txt).",
1251
+ fix: "Rename the file using only letters, digits, dashes, and underscores."
1252
+ },
1214
1253
  {
1215
1254
  code: "WD950",
1216
1255
  title: "Invalid `rss_limit`",
@@ -1296,7 +1335,15 @@ export function errorsMarkdown() {
1296
1335
  out.push("| Code | Error | Cause | Fix | Example |");
1297
1336
  out.push("| --- | --- | --- | --- | --- |");
1298
1337
  for (const row of rows) {
1299
- const example = row.example ? `\`${escapeCell(row.example)}\`` : "";
1338
+ // A multi-line example (the `.skin` at-rule blocks are block constructs)
1339
+ // becomes one code span per line joined by <br>, keeping the table row
1340
+ // on a single markdown line.
1341
+ const example = row.example
1342
+ ? row.example
1343
+ .split("\n")
1344
+ .map((line) => `\`${escapeCell(line)}\``)
1345
+ .join("<br>")
1346
+ : "";
1300
1347
  out.push(
1301
1348
  `| \`${row.code}\` | ${escapeCell(row.title)} | ${escapeCell(row.cause)} | ${escapeCell(row.fix)} | ${example} |`
1302
1349
  );
@@ -36,6 +36,8 @@ import fs from "node:fs";
36
36
  import os from "node:os";
37
37
  import path from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
+ import { fsReader } from "./compiler/fs-reader.js";
40
+ import { isAllowedInclude } from "./compiler/includes.js";
39
41
 
40
42
  /**
41
43
  * @typedef {import("./config.js").Paths} Paths
@@ -287,21 +289,26 @@ export function subsetFonts(paths, options = {}) {
287
289
  * build is synchronous; the wasm API is not), communicating through temp JSON
288
290
  * files rather than stdio so a large payload never hits a pipe buffer. The
289
291
  * worker always writes a results file — a missing or unparsable one means the
290
- * child itself died, which is reported as `unavailable` (the build then behaves
291
- * as if the toolchain were absent: a hint, no subsets, no failure).
292
+ * child itself died (crashed, or killed by the spawn timeout), which is
293
+ * reported as `unavailable` (the build then behaves as if the toolchain were
294
+ * absent: a hint, no subsets, no failure).
292
295
  * @param {{ charset: number[], jobs: SubsetJob[] }} payload
293
296
  * @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
294
297
  * Injectable for tests; the real one is `spawnSync`.
298
+ * @param {number} [timeoutMs] The worker is killed after this long (default
299
+ * two minutes), so a hung toolchain call cannot stall a build forever. The
300
+ * kill lands in the same `unavailable` path as any other dead child.
295
301
  * @returns {SubsetResult}
296
302
  */
297
- export function runSubsetJobs(payload, spawn = /** @type {any} */ (spawnSync)) {
303
+ export function runSubsetJobs(payload, spawn = /** @type {any} */ (spawnSync), timeoutMs = 120000) {
298
304
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wd-font-subset-"));
299
305
  try {
300
306
  const jobsFile = path.join(dir, "jobs.json");
301
307
  const resultsFile = path.join(dir, "results.json");
302
308
  fs.writeFileSync(jobsFile, JSON.stringify(payload));
303
309
  spawn(process.execPath, [WORKER_PATH, jobsFile, resultsFile], {
304
- stdio: ["ignore", "ignore", "inherit"]
310
+ stdio: ["ignore", "ignore", "inherit"],
311
+ timeout: timeoutMs
305
312
  });
306
313
  if (!fs.existsSync(resultsFile)) return { unavailable: true, failed: [] };
307
314
  try {
@@ -391,9 +398,10 @@ function fontUrlsInCss(css) {
391
398
  * Resolve a url() target from an emitted `@font-face` to the project font file
392
399
  * it names, or null when it is not a subset candidate: anything not
393
400
  * leading-slash (remote, protocol-relative, `data:`, relative), a non-subsettable
394
- * extension (already-woff2 included), an un-decodable or escaping path, or a
395
- * file that does not exist. `/__wd/media/<rel>` maps to the shelf; every other
396
- * absolute path maps to `site/pages`.
401
+ * extension (already-woff2 included), an un-decodable or escaping path, a
402
+ * symlink (whose target could live anywhere on disk, whatever the link's own
403
+ * path says), or a file that does not exist. `/__wd/media/<rel>` maps to the
404
+ * shelf; every other absolute path maps to `site/pages`.
397
405
  * @param {Paths} paths
398
406
  * @param {string} url The raw url() target.
399
407
  * @returns {FontRef | null}
@@ -416,6 +424,16 @@ function resolveFontUrl(paths, url) {
416
424
  const source = path.normalize(path.join(base, rel));
417
425
  if (!source.startsWith(base + path.sep)) return null;
418
426
  if (!fs.existsSync(source)) return null;
427
+ // Symlinks are never subsetted, matching the asset-copy convention
428
+ // (`emitPageAssets` skips them entirely, and so does the image pre-pass):
429
+ // the lexical containment check above reads the path as WRITTEN, so a link
430
+ // sitting inside the project could point anywhere on disk and the target's
431
+ // bytes would be read and published as a subset. An ancestor directory can
432
+ // be a symlink with the file itself plain, so the canonical (realpath)
433
+ // containment check `@include` uses runs too — a font whose real path
434
+ // escapes site/pages and site/_ is simply "not project-local" here.
435
+ if (fs.lstatSync(source).isSymbolicLink()) return null;
436
+ if (!isAllowedInclude(source, paths, fsReader())) return null;
419
437
  const subsetUrl = `${clean.slice(0, clean.length - ext.length)}-subset.woff2`;
420
438
  return { url, source, subsetUrl, subsetDist: path.join(paths.distRoot, subsetUrl.slice(1)) };
421
439
  }
@@ -46,7 +46,10 @@ export async function runJobsFile(jobsFile, resultsFile) {
46
46
  // loses the EXIF tag with its pixels physically rotated, so it displays
47
47
  // exactly like the tag-carrying original. `withoutEnlargement` is belt and
48
48
  // braces — the pre-pass only ever queues widths below the intrinsic size.
49
- let pipeline = sharp(job.source)
49
+ // `limitInputPixels` caps the decompression-bomb surface: 100 MP is above
50
+ // any real photo, and an oversized input fails as a per-job error (a hint
51
+ // naming the file), never a build failure.
52
+ let pipeline = sharp(job.source, { limitInputPixels: 1e8 })
50
53
  .rotate()
51
54
  .resize({ width: job.width, withoutEnlargement: true });
52
55
  if (job.format === "webp") pipeline = pipeline.webp({ quality: 82 });
@@ -228,21 +228,26 @@ export function generateImageVariants(paths, options = {}) {
228
228
  * build is synchronous; sharp is not), communicating through temp JSON files
229
229
  * rather than stdio so a large job list never hits a pipe buffer. The worker
230
230
  * always writes a results file — a missing or unparsable one means the child
231
- * itself died, which is reported as `unavailable` (the build then behaves as
232
- * if sharp were absent: a hint, no variants, no failure).
231
+ * itself died (crashed, or killed by the spawn timeout), which is reported as
232
+ * `unavailable` (the build then behaves as if sharp were absent: a hint, no
233
+ * variants, no failure).
233
234
  * @param {ResizeJob[]} jobs
234
235
  * @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
235
236
  * Injectable for tests; the real one is `spawnSync`.
237
+ * @param {number} [timeoutMs] The worker is killed after this long (default
238
+ * two minutes), so a hung sharp call cannot stall a build forever. The kill
239
+ * lands in the same `unavailable` path as any other dead child.
236
240
  * @returns {ResizeResult}
237
241
  */
238
- export function runResizeJobs(jobs, spawn = /** @type {any} */ (spawnSync)) {
242
+ export function runResizeJobs(jobs, spawn = /** @type {any} */ (spawnSync), timeoutMs = 120000) {
239
243
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wd-img-variants-"));
240
244
  try {
241
245
  const jobsFile = path.join(dir, "jobs.json");
242
246
  const resultsFile = path.join(dir, "results.json");
243
247
  fs.writeFileSync(jobsFile, JSON.stringify(jobs));
244
248
  spawn(process.execPath, [WORKER_PATH, jobsFile, resultsFile], {
245
- stdio: ["ignore", "ignore", "inherit"]
249
+ stdio: ["ignore", "ignore", "inherit"],
250
+ timeout: timeoutMs
246
251
  });
247
252
  if (!fs.existsSync(resultsFile)) return { unavailable: true, failed: [] };
248
253
  try {
package/src/router.js CHANGED
@@ -88,6 +88,11 @@ export function isDraft(meta) {
88
88
 
89
89
  /**
90
90
  * Derive a public route path from a source file path.
91
+ *
92
+ * Fail-closed on control/whitespace characters: the route string flows RAW
93
+ * into generated config (`dist/_headers` blocks, llms.txt lines), where a
94
+ * newline in a filename would inject whole config lines. Rejecting here, at
95
+ * the one route-derivation chokepoint, protects every downstream sink at once.
91
96
  * @param {string} routesRoot Absolute path to `site/pages`.
92
97
  * @param {string} file Absolute path to the source file.
93
98
  * @returns {string}
@@ -98,9 +103,20 @@ export function routeFromFile(routesRoot, file) {
98
103
  const parts = rel.split(path.sep);
99
104
  if (parts.at(-1) === "index") parts.pop();
100
105
  const route = `/${parts.join("/")}`;
106
+ if (ROUTE_UNSAFE_CHARS.test(route)) {
107
+ throw wdError(
108
+ `Refusing to route ${file} — a page filename must not contain whitespace or ` +
109
+ "control characters: the route is written verbatim into generated config " +
110
+ "(dist/_headers, llms.txt), where such a character would corrupt the file.",
111
+ { code: "WD908", file }
112
+ );
113
+ }
101
114
  return route === "/" ? "/" : `${route}/`;
102
115
  }
103
116
 
117
+ /** Whitespace (`\s`) and control characters (`\p{Cc}`: C0, DEL, C1) a route string must never carry. */
118
+ const ROUTE_UNSAFE_CHARS = /[\s\p{Cc}]/u;
119
+
104
120
  /**
105
121
  * Map a public route to the `index.html` output path under `distRoot`.
106
122
  *