@json-to-office/jto 0.20.0 → 0.22.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 (26) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +700 -129
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{HomePage-DePp4KBa.js → HomePage-BW2AGCC2.js} +3 -3
  5. package/dist/client/assets/{HomePage-DePp4KBa.js.map → HomePage-BW2AGCC2.js.map} +1 -1
  6. package/dist/client/assets/{JsonEditorPage-WCCH_wWH.js → JsonEditorPage-Bclb98Ti.js} +3 -3
  7. package/dist/client/assets/{JsonEditorPage-WCCH_wWH.js.map → JsonEditorPage-Bclb98Ti.js.map} +1 -1
  8. package/dist/client/assets/{MonacoPluginProvider-CMj42yVI.js → MonacoPluginProvider-BbjmAGrJ.js} +3 -3
  9. package/dist/client/assets/{MonacoPluginProvider-CMj42yVI.js.map → MonacoPluginProvider-BbjmAGrJ.js.map} +1 -1
  10. package/dist/client/assets/{button-BxE8yzur.js → button-CSrOcD5F.js} +2 -2
  11. package/dist/client/assets/{button-BxE8yzur.js.map → button-CSrOcD5F.js.map} +1 -1
  12. package/dist/client/assets/{editor-BpCI3VcW.js → editor-CGjdiUtd.js} +2 -2
  13. package/dist/client/assets/{editor-BpCI3VcW.js.map → editor-CGjdiUtd.js.map} +1 -1
  14. package/dist/client/assets/{editor-monaco-json-Bj2PTnna.js → editor-monaco-json-Bw2M2Xcf.js} +2 -2
  15. package/dist/client/assets/{editor-monaco-json-Bj2PTnna.js.map → editor-monaco-json-Bw2M2Xcf.js.map} +1 -1
  16. package/dist/client/assets/index-Bn9d6cEh.js +5 -0
  17. package/dist/client/assets/index-Bn9d6cEh.js.map +1 -0
  18. package/dist/client/assets/{preview-WFz8KjYy.js → preview-C51sEB99.js} +2 -2
  19. package/dist/client/assets/{preview-WFz8KjYy.js.map → preview-C51sEB99.js.map} +1 -1
  20. package/dist/client/index.html +1 -1
  21. package/dist/render-server.d.ts +39 -1
  22. package/dist/render-server.js +848 -75
  23. package/dist/render-server.js.map +1 -1
  24. package/package.json +10 -14
  25. package/dist/client/assets/index-DTfptUZM.js +0 -5
  26. package/dist/client/assets/index-DTfptUZM.js.map +0 -1
@@ -1,6 +1,10 @@
1
1
  // src/render-server.ts
2
- import { serve } from "@hono/node-server";
2
+ import { fileURLToPath } from "url";
3
+ import { resolve } from "path";
3
4
  import { Hono } from "hono";
5
+ import { bodyLimit as bodyLimit2 } from "hono/body-limit";
6
+ import { HTTPException as HTTPException4 } from "hono/http-exception";
7
+ import { secureHeaders } from "hono/secure-headers";
4
8
 
5
9
  // src/server/rasterize-route.ts
6
10
  import { Type } from "@sinclair/typebox";
@@ -63,6 +67,319 @@ function getValidated(c, target) {
63
67
  return validatedData[target];
64
68
  }
65
69
 
70
+ // src/server/security/outbound-source-policy.ts
71
+ var UnsafeOutboundSourceError = class extends Error {
72
+ constructor(path, reason) {
73
+ super(`Unsafe outbound source at ${path}: ${reason}`);
74
+ this.path = path;
75
+ this.name = "UnsafeOutboundSourceError";
76
+ }
77
+ };
78
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
79
+ var COMPONENT_SOURCE_NAMES = /* @__PURE__ */ new Set(["image"]);
80
+ function isRecord(value) {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+ function hostMatches(host, pattern) {
84
+ const normalized = pattern.toLowerCase().replace(/\.$/, "");
85
+ if (normalized.startsWith("*.")) {
86
+ const suffix = normalized.slice(1);
87
+ return host.endsWith(suffix) && host.length > suffix.length;
88
+ }
89
+ return host === normalized;
90
+ }
91
+ function isPrivateIpv4(host) {
92
+ const parts = host.split(".");
93
+ if (parts.length !== 4) return false;
94
+ const octets = parts.map(Number);
95
+ if (octets.some(
96
+ (part, index) => !Number.isInteger(part) || part < 0 || part > 255 || String(part) !== parts[index]
97
+ )) {
98
+ return false;
99
+ }
100
+ const [a, b] = octets;
101
+ return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && (b === 0 || b === 168) || a === 198 && (b === 18 || b === 19) || a >= 224;
102
+ }
103
+ function isPrivateHost(host) {
104
+ const normalized = host.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
105
+ if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::" || normalized === "::1") {
106
+ return true;
107
+ }
108
+ if (isPrivateIpv4(normalized)) return true;
109
+ if (normalized.startsWith("::ffff:")) {
110
+ const mapped = normalized.slice("::ffff:".length);
111
+ if (isPrivateIpv4(mapped)) return true;
112
+ const groups = mapped.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
113
+ if (groups) {
114
+ const value = Number.parseInt(groups[1], 16) * 65536 + Number.parseInt(groups[2], 16);
115
+ return isPrivateIpv4(
116
+ [
117
+ value >>> 24,
118
+ value >>> 16 & 255,
119
+ value >>> 8 & 255,
120
+ value & 255
121
+ ].join(".")
122
+ );
123
+ }
124
+ }
125
+ const firstIpv6Group = Number.parseInt(normalized.split(":")[0] || "0", 16);
126
+ return firstIpv6Group >= 64512 && firstIpv6Group <= 65023 || firstIpv6Group >= 65152 && firstIpv6Group <= 65215;
127
+ }
128
+ function assertAllowedUrl(source, path, allowedHosts) {
129
+ if (source.startsWith("data:")) return;
130
+ let url;
131
+ try {
132
+ url = new URL(source);
133
+ } catch {
134
+ throw new UnsafeOutboundSourceError(
135
+ path,
136
+ "local and relative file paths are disabled for HTTP requests"
137
+ );
138
+ }
139
+ if (url.protocol !== "https:") {
140
+ throw new UnsafeOutboundSourceError(path, "only HTTPS URLs are allowed");
141
+ }
142
+ if (url.username || url.password) {
143
+ throw new UnsafeOutboundSourceError(
144
+ path,
145
+ "URLs containing credentials are not allowed"
146
+ );
147
+ }
148
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
149
+ if (isPrivateHost(host)) {
150
+ throw new UnsafeOutboundSourceError(
151
+ path,
152
+ `private or local host "${host}" is not allowed`
153
+ );
154
+ }
155
+ if (!allowedHosts.some((pattern) => hostMatches(host, pattern))) {
156
+ throw new UnsafeOutboundSourceError(
157
+ path,
158
+ `host "${host}" is not in OUTBOUND_HOST_ALLOWLIST`
159
+ );
160
+ }
161
+ }
162
+ function extractCssReferences(input) {
163
+ const references = [];
164
+ const urlPattern = /url\(\s*(['"]?)([^)'"\s]+)\1\s*\)/gi;
165
+ const importPattern = /@import\s+(?:url\(\s*)?(['"])([^'"]+)\1/gi;
166
+ for (const pattern of [urlPattern, importPattern]) {
167
+ let match;
168
+ while (match = pattern.exec(input)) references.push(match[2]);
169
+ }
170
+ return references;
171
+ }
172
+ function extractAbsoluteUrls(input) {
173
+ return input.match(/(?:https?|file):\/\/[^\s'"<>)}\]]+/gi) ?? [];
174
+ }
175
+ function assertSafeSvg(svg, path, allowedHosts) {
176
+ if (/<\s*script\b/i.test(svg) || /<\s*(?:foreignObject|iframe|object|embed)\b/i.test(svg) || /\bon[a-z]+\s*=/i.test(svg)) {
177
+ throw new UnsafeOutboundSourceError(path, "active SVG content is disabled");
178
+ }
179
+ if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(svg)) {
180
+ throw new UnsafeOutboundSourceError(
181
+ path,
182
+ "SVG document types and entities are disabled"
183
+ );
184
+ }
185
+ const refPattern = /\b(?:href|src)\s*=\s*(['"])(.*?)\1/gi;
186
+ let match;
187
+ while (match = refPattern.exec(svg)) {
188
+ const ref = match[2].trim();
189
+ if (!ref || ref.startsWith("#") || ref.startsWith("data:")) continue;
190
+ assertAllowedUrl(ref, path, allowedHosts);
191
+ }
192
+ for (const ref of extractCssReferences(svg)) {
193
+ if (ref.startsWith("#") || ref.startsWith("data:")) continue;
194
+ assertAllowedUrl(ref, path, allowedHosts);
195
+ }
196
+ }
197
+ function assertSafeEncodedImage(source, path, allowedHosts) {
198
+ let candidate = source.trim();
199
+ let declaredSvg = false;
200
+ if (candidate.startsWith("data:")) {
201
+ const comma = candidate.indexOf(",");
202
+ if (comma < 0) {
203
+ throw new UnsafeOutboundSourceError(path, "malformed image data URL");
204
+ }
205
+ const metadata = candidate.slice(5, comma);
206
+ declaredSvg = metadata.split(";")[0].toLowerCase() === "image/svg+xml";
207
+ if (!declaredSvg) return;
208
+ const payload = candidate.slice(comma + 1);
209
+ try {
210
+ candidate = /(?:^|;)base64(?:;|$)/i.test(metadata) ? Buffer.from(payload, "base64").toString("utf8") : decodeURIComponent(payload);
211
+ } catch {
212
+ throw new UnsafeOutboundSourceError(path, "malformed SVG data URL");
213
+ }
214
+ } else if (!/<\s*svg\b/i.test(candidate)) {
215
+ candidate = Buffer.from(candidate, "base64").toString("utf8");
216
+ }
217
+ const svgStart = candidate.search(/<\s*svg\b/i);
218
+ if (svgStart < 0) {
219
+ if (declaredSvg) {
220
+ throw new UnsafeOutboundSourceError(path, "malformed SVG image data");
221
+ }
222
+ return;
223
+ }
224
+ assertSafeSvg(candidate, path, allowedHosts);
225
+ }
226
+ function assertSafeResources(resources, path, allowedHosts) {
227
+ if (typeof resources.js === "string" && resources.js.trim()) {
228
+ throw new UnsafeOutboundSourceError(
229
+ `${path}.js`,
230
+ "remote renderer JavaScript resources are disabled"
231
+ );
232
+ }
233
+ if (typeof resources.css === "string") {
234
+ for (const ref of extractCssReferences(resources.css)) {
235
+ if (ref.startsWith("data:")) continue;
236
+ assertAllowedUrl(ref, `${path}.css`, allowedHosts);
237
+ }
238
+ }
239
+ if (Array.isArray(resources.files) && resources.files.length > 0) {
240
+ throw new UnsafeOutboundSourceError(
241
+ `${path}.files`,
242
+ "remote renderer JavaScript resources are disabled"
243
+ );
244
+ }
245
+ }
246
+ function assertNoRemoteReferences(value, path, allowedHosts, seen) {
247
+ if (typeof value === "string") {
248
+ if (/\b(?:fetch|XMLHttpRequest|WebSocket|EventSource)\s*\(/i.test(value) || /\b(?:javascript|file):/i.test(value)) {
249
+ throw new UnsafeOutboundSourceError(
250
+ path,
251
+ "network-capable JavaScript and file URLs are disabled"
252
+ );
253
+ }
254
+ for (const url of [
255
+ ...extractAbsoluteUrls(value),
256
+ ...extractCssReferences(value)
257
+ ]) {
258
+ if (url.startsWith("data:")) continue;
259
+ assertAllowedUrl(url, path, allowedHosts);
260
+ }
261
+ if (/^\/\//.test(value.trim())) {
262
+ throw new UnsafeOutboundSourceError(
263
+ path,
264
+ "protocol-relative URLs are disabled"
265
+ );
266
+ }
267
+ return;
268
+ }
269
+ if (!value || typeof value !== "object" || seen.has(value)) return;
270
+ seen.add(value);
271
+ if (Array.isArray(value)) {
272
+ value.forEach(
273
+ (entry, index) => assertNoRemoteReferences(entry, `${path}[${index}]`, allowedHosts, seen)
274
+ );
275
+ return;
276
+ }
277
+ for (const [key, child] of Object.entries(value)) {
278
+ assertNoRemoteReferences(child, `${path}.${key}`, allowedHosts, seen);
279
+ }
280
+ }
281
+ function visit(value, path, containerKey, allowedHosts, seen) {
282
+ if (!value || typeof value !== "object" || seen.has(value)) return;
283
+ seen.add(value);
284
+ if (Array.isArray(value)) {
285
+ value.forEach(
286
+ (entry, index) => visit(entry, `${path}[${index}]`, containerKey, allowedHosts, seen)
287
+ );
288
+ return;
289
+ }
290
+ const record = value;
291
+ for (const key of Object.keys(record)) {
292
+ if (DANGEROUS_KEYS.has(key)) {
293
+ throw new UnsafeOutboundSourceError(
294
+ `${path}.${key}`,
295
+ "prototype mutation keys are disabled"
296
+ );
297
+ }
298
+ }
299
+ const kind = typeof record.kind === "string" ? record.kind : void 0;
300
+ if (kind === "file" && typeof record.path === "string") {
301
+ throw new UnsafeOutboundSourceError(
302
+ `${path}.path`,
303
+ "local file sources are disabled for HTTP requests"
304
+ );
305
+ }
306
+ if ((kind === "url" || kind === "variable") && typeof record.url === "string") {
307
+ assertAllowedUrl(record.url, `${path}.url`, allowedHosts);
308
+ }
309
+ const componentName = typeof record.name === "string" ? record.name.toLowerCase() : void 0;
310
+ const props = isRecord(record.props) ? record.props : void 0;
311
+ if (componentName && props) {
312
+ if (COMPONENT_SOURCE_NAMES.has(componentName) && typeof props.path === "string") {
313
+ assertAllowedUrl(props.path, `${path}.props.path`, allowedHosts);
314
+ assertSafeEncodedImage(props.path, `${path}.props.path`, allowedHosts);
315
+ }
316
+ if (COMPONENT_SOURCE_NAMES.has(componentName) && typeof props.base64 === "string") {
317
+ assertSafeEncodedImage(
318
+ props.base64,
319
+ `${path}.props.base64`,
320
+ allowedHosts
321
+ );
322
+ }
323
+ if ((componentName === "highcharts" || componentName === "visual") && typeof props.serverUrl === "string") {
324
+ assertAllowedUrl(
325
+ props.serverUrl,
326
+ `${path}.props.serverUrl`,
327
+ allowedHosts
328
+ );
329
+ }
330
+ if (componentName === "highcharts") {
331
+ if (isRecord(props.resources)) {
332
+ assertSafeResources(
333
+ props.resources,
334
+ `${path}.props.resources`,
335
+ allowedHosts
336
+ );
337
+ }
338
+ assertNoRemoteReferences(
339
+ props.options,
340
+ `${path}.props.options`,
341
+ allowedHosts,
342
+ /* @__PURE__ */ new WeakSet()
343
+ );
344
+ }
345
+ }
346
+ if (containerKey === "image" && typeof record.path === "string") {
347
+ assertAllowedUrl(record.path, `${path}.path`, allowedHosts);
348
+ assertSafeEncodedImage(record.path, `${path}.path`, allowedHosts);
349
+ }
350
+ if (typeof record.svg === "string") {
351
+ assertSafeSvg(record.svg, `${path}.svg`, allowedHosts);
352
+ }
353
+ if (containerKey === "resources") {
354
+ assertSafeResources(record, path, allowedHosts);
355
+ }
356
+ for (const [key, child] of Object.entries(record)) {
357
+ visit(child, `${path}.${key}`, key, allowedHosts, seen);
358
+ }
359
+ }
360
+ function assertSafeOutboundSources(value, policy, rootPath = "request") {
361
+ if (policy.mode === "development") return;
362
+ let parsed = value;
363
+ if (typeof value === "string") {
364
+ try {
365
+ parsed = JSON.parse(value);
366
+ } catch {
367
+ return;
368
+ }
369
+ }
370
+ visit(parsed, rootPath, void 0, policy.allowedHosts, /* @__PURE__ */ new WeakSet());
371
+ }
372
+ function assertSafeRendererPayload(value, policy) {
373
+ assertSafeOutboundSources(value, policy, "export");
374
+ if (policy.mode === "development" || !isRecord(value)) return;
375
+ assertNoRemoteReferences(
376
+ value.infile,
377
+ "export.infile",
378
+ policy.allowedHosts,
379
+ /* @__PURE__ */ new WeakSet()
380
+ );
381
+ }
382
+
66
383
  // src/server/rasterize-route.ts
67
384
  var RasterizeRequestSchema = Type.Object(
68
385
  {
@@ -105,6 +422,13 @@ function registerRasterizeRoute(router, options = {}) {
105
422
  async (c) => {
106
423
  const { presentation, dpi } = getValidated(c, "json");
107
424
  try {
425
+ if (options.sourcePolicy) {
426
+ assertSafeOutboundSources(
427
+ presentation,
428
+ options.sourcePolicy,
429
+ "presentation"
430
+ );
431
+ }
108
432
  const result = await getRasterizer()({
109
433
  presentation,
110
434
  dpi: clampVisualDpi(dpi ?? DEFAULT_VISUAL_DPI)
@@ -113,6 +437,9 @@ function registerRasterizeRoute(router, options = {}) {
113
437
  } catch (error) {
114
438
  options.onError?.(error);
115
439
  if (error instanceof HTTPException2) throw error;
440
+ if (error instanceof UnsafeOutboundSourceError) {
441
+ throw new HTTPException2(400, { message: error.message });
442
+ }
116
443
  const msg = error instanceof Error ? error.message.toLowerCase() : String(error);
117
444
  if (msg.includes("not found") || msg.includes("rasterization needs")) {
118
445
  throw new HTTPException2(503, { message: error.message });
@@ -130,24 +457,41 @@ function registerRasterizeRoute(router, options = {}) {
130
457
 
131
458
  // src/server/middleware/hono/rate-limit.ts
132
459
  import { HTTPException as HTTPException3 } from "hono/http-exception";
133
- var rateLimitStore = /* @__PURE__ */ new Map();
460
+ function clientAddress(c, trustProxy) {
461
+ if (trustProxy) {
462
+ const forwarded = c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",")[0]?.trim();
463
+ if (forwarded) return forwarded;
464
+ }
465
+ const incoming = c.env?.incoming || c.env?.req;
466
+ return incoming?.socket?.remoteAddress || "anonymous";
467
+ }
134
468
  var rateLimiter = (options) => {
135
469
  const { limit, window, keyGenerator } = options;
470
+ const maxEntries = options.maxEntries ?? 1e4;
471
+ const rateLimitStore = /* @__PURE__ */ new Map();
472
+ let lastCleanup = 0;
136
473
  return async (c, next) => {
137
- const key = keyGenerator ? keyGenerator(c) : c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous";
474
+ const clientKey = keyGenerator ? keyGenerator(c) : clientAddress(c, options.trustProxy === true);
475
+ const namespace = typeof options.namespace === "function" ? options.namespace(c) : options.namespace || `${c.req.method}:${c.req.path}`;
476
+ const key = `${namespace}:${String(clientKey).slice(0, 256)}`;
138
477
  const now = Date.now();
139
- for (const [k, v] of rateLimitStore.entries()) {
140
- if (v.resetTime < now) {
141
- rateLimitStore.delete(k);
478
+ if (now - lastCleanup >= Math.min(window, 6e4)) {
479
+ for (const [storedKey, value] of rateLimitStore.entries()) {
480
+ if (value.resetTime <= now) rateLimitStore.delete(storedKey);
142
481
  }
482
+ lastCleanup = now;
143
483
  }
144
484
  const record = rateLimitStore.get(key);
145
485
  if (!record) {
486
+ if (rateLimitStore.size >= maxEntries) {
487
+ const oldestKey = rateLimitStore.keys().next().value;
488
+ if (oldestKey) rateLimitStore.delete(oldestKey);
489
+ }
146
490
  rateLimitStore.set(key, {
147
491
  count: 1,
148
492
  resetTime: now + window
149
493
  });
150
- } else if (record.resetTime < now) {
494
+ } else if (record.resetTime <= now) {
151
495
  record.count = 1;
152
496
  record.resetTime = now + window;
153
497
  } else if (record.count >= limit) {
@@ -173,83 +517,512 @@ var rateLimiter = (options) => {
173
517
  };
174
518
  };
175
519
 
520
+ // src/server/middleware/hono/concurrency-limit.ts
521
+ function concurrencyLimiter(options) {
522
+ const limit = Math.max(1, Math.floor(options.limit));
523
+ const retryAfterSeconds = options.retryAfterSeconds ?? 1;
524
+ let active = 0;
525
+ return async (c, next) => {
526
+ c.header("X-Concurrency-Limit", String(limit));
527
+ if (active >= limit) {
528
+ c.header("Retry-After", String(retryAfterSeconds));
529
+ return c.json(
530
+ {
531
+ success: false,
532
+ error: "Server is at capacity",
533
+ code: "CONCURRENCY_LIMIT_EXCEEDED"
534
+ },
535
+ 503
536
+ );
537
+ }
538
+ active += 1;
539
+ c.header("X-Concurrency-Remaining", String(Math.max(0, limit - active)));
540
+ try {
541
+ await next();
542
+ } finally {
543
+ active -= 1;
544
+ }
545
+ };
546
+ }
547
+
548
+ // src/server/middleware/hono/auth.ts
549
+ import { createHash, timingSafeEqual } from "crypto";
550
+
551
+ // src/server/config/index.ts
552
+ import dotenv from "dotenv";
553
+ dotenv.config();
554
+ function positiveInteger(value, fallback) {
555
+ const parsed = Number.parseInt(value ?? "", 10);
556
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
557
+ }
558
+ function parseAuthMode(value, nodeEnv) {
559
+ if (value === "auto" || value === "required" || value === "disabled") {
560
+ return value;
561
+ }
562
+ return nodeEnv === "production" ? "required" : "auto";
563
+ }
564
+ function parseOutboundSourceMode(value, nodeEnv) {
565
+ if (value === "development" || value === "safe") return value;
566
+ return nodeEnv === "production" ? "safe" : "development";
567
+ }
568
+ function normalizeNodeEnv(value) {
569
+ return value === "development" || value === "test" ? value : "production";
570
+ }
571
+ function parseEnv(env) {
572
+ const nodeEnv = normalizeNodeEnv(env.NODE_ENV || "development");
573
+ return {
574
+ NODE_ENV: nodeEnv,
575
+ // No PORT here: the listener is opened by the dev server from the CLI
576
+ // config (`-p` > `server.port` > `PORT` env > format default), so a second
577
+ // copy of it in this module would be silently ignored.
578
+ CORS_ORIGIN: env.CORS_ORIGIN || "*",
579
+ API_KEY: env.API_KEY,
580
+ API_KEY_HEADER: env.API_KEY_HEADER || "x-api-key",
581
+ API_AUTH_MODE: parseAuthMode(env.API_AUTH_MODE, nodeEnv),
582
+ RATE_LIMIT_WINDOW_MS: positiveInteger(env.RATE_LIMIT_WINDOW_MS, 9e5),
583
+ RATE_LIMIT_MAX: positiveInteger(
584
+ env.RATE_LIMIT_MAX,
585
+ nodeEnv === "production" ? 100 : 1e3
586
+ ),
587
+ TRUST_PROXY_HEADERS: env.TRUST_PROXY_HEADERS === "true",
588
+ MAX_FILE_SIZE: positiveInteger(env.MAX_FILE_SIZE, 10 * 1024 * 1024),
589
+ MAX_REQUEST_BODY_SIZE: positiveInteger(
590
+ env.MAX_REQUEST_BODY_SIZE,
591
+ 32 * 1024 * 1024
592
+ ),
593
+ MAX_CONCURRENT_REQUESTS: positiveInteger(
594
+ env.MAX_CONCURRENT_REQUESTS,
595
+ nodeEnv === "production" ? 8 : 64
596
+ ),
597
+ OUTBOUND_SOURCE_MODE: parseOutboundSourceMode(
598
+ env.OUTBOUND_SOURCE_MODE,
599
+ nodeEnv
600
+ ),
601
+ OUTBOUND_HOST_ALLOWLIST: (env.OUTBOUND_HOST_ALLOWLIST || "").split(",").map((host) => host.trim().toLowerCase()).filter(Boolean),
602
+ LIBREOFFICE_PATH: env.LIBREOFFICE_PATH,
603
+ LIBREOFFICE_TIMEOUT_MS: env.LIBREOFFICE_TIMEOUT_MS ? positiveInteger(env.LIBREOFFICE_TIMEOUT_MS, 3e4) : 3e4,
604
+ LOG_LEVEL: env.LOG_LEVEL || "info",
605
+ CACHE_ENABLED: env.CACHE_ENABLED !== "false",
606
+ CACHE_MAX_SIZE_MB: positiveInteger(env.CACHE_MAX_SIZE_MB, 100),
607
+ CACHE_TTL_SECONDS: positiveInteger(env.CACHE_TTL_SECONDS, 3600),
608
+ CACHE_MAX_ITEMS: positiveInteger(env.CACHE_MAX_ITEMS, 1e3)
609
+ };
610
+ }
611
+ var parsedEnv = parseEnv(process.env);
612
+ var config = {
613
+ ...parsedEnv,
614
+ isDevelopment: parsedEnv.NODE_ENV === "development",
615
+ isProduction: parsedEnv.NODE_ENV === "production",
616
+ isTest: parsedEnv.NODE_ENV === "test",
617
+ features: {
618
+ apiKey: parsedEnv.API_AUTH_MODE !== "disabled",
619
+ cache: parsedEnv.CACHE_ENABLED
620
+ },
621
+ cors: {
622
+ origin: parsedEnv.CORS_ORIGIN === "*" ? parsedEnv.CORS_ORIGIN : parsedEnv.CORS_ORIGIN.split(",").map((o) => o.trim()),
623
+ credentials: true
624
+ },
625
+ rateLimit: {
626
+ windowMs: parsedEnv.RATE_LIMIT_WINDOW_MS,
627
+ max: parsedEnv.RATE_LIMIT_MAX,
628
+ trustProxy: parsedEnv.TRUST_PROXY_HEADERS
629
+ },
630
+ requestLimits: {
631
+ maxBodySize: parsedEnv.MAX_REQUEST_BODY_SIZE,
632
+ maxConcurrent: parsedEnv.MAX_CONCURRENT_REQUESTS,
633
+ maxFileSize: parsedEnv.MAX_FILE_SIZE
634
+ },
635
+ outboundSources: {
636
+ mode: parsedEnv.OUTBOUND_SOURCE_MODE,
637
+ allowedHosts: parsedEnv.OUTBOUND_HOST_ALLOWLIST
638
+ },
639
+ cache: {
640
+ enabled: parsedEnv.CACHE_ENABLED,
641
+ maxSizeMB: parsedEnv.CACHE_MAX_SIZE_MB,
642
+ ttlSeconds: parsedEnv.CACHE_TTL_SECONDS,
643
+ maxItems: parsedEnv.CACHE_MAX_ITEMS
644
+ }
645
+ };
646
+
647
+ // src/server/middleware/hono/auth.ts
648
+ function keysEqual(received, expected) {
649
+ const receivedDigest = createHash("sha256").update(received).digest();
650
+ const expectedDigest = createHash("sha256").update(expected).digest();
651
+ return timingSafeEqual(receivedDigest, expectedDigest);
652
+ }
653
+ function readCredential(headers, headerName) {
654
+ const direct = headers.get(headerName)?.trim();
655
+ if (direct) {
656
+ if (headerName.toLowerCase() === "authorization" && direct.toLowerCase().startsWith("bearer ")) {
657
+ return direct.slice(7).trim() || void 0;
658
+ }
659
+ return direct;
660
+ }
661
+ const authorization = headers.get("authorization")?.trim();
662
+ if (authorization?.toLowerCase().startsWith("bearer ")) {
663
+ return authorization.slice(7).trim() || void 0;
664
+ }
665
+ return void 0;
666
+ }
667
+ function createApiKeyAuthMiddleware(options) {
668
+ const headerName = options.headerName || "x-api-key";
669
+ return async (c, next) => {
670
+ if (c.req.method === "OPTIONS" || options.mode === "disabled") {
671
+ return next();
672
+ }
673
+ if (!options.apiKey) {
674
+ if (options.mode === "auto") return next();
675
+ return c.json(
676
+ {
677
+ success: false,
678
+ error: "API authentication is not configured",
679
+ code: "AUTH_CONFIGURATION_ERROR"
680
+ },
681
+ 503
682
+ );
683
+ }
684
+ const apiKey = readCredential(c.req.raw.headers, headerName);
685
+ if (!apiKey) {
686
+ return c.json(
687
+ {
688
+ success: false,
689
+ error: "API key required",
690
+ code: "UNAUTHORIZED"
691
+ },
692
+ 401
693
+ );
694
+ }
695
+ if (!keysEqual(apiKey, options.apiKey)) {
696
+ return c.json(
697
+ {
698
+ success: false,
699
+ error: "Invalid API key",
700
+ code: "UNAUTHORIZED"
701
+ },
702
+ 401
703
+ );
704
+ }
705
+ await next();
706
+ };
707
+ }
708
+ var apiKeyAuthMiddleware = createApiKeyAuthMiddleware({
709
+ mode: config.API_AUTH_MODE,
710
+ apiKey: config.API_KEY,
711
+ headerName: config.API_KEY_HEADER
712
+ });
713
+
176
714
  // src/render-server.ts
177
- var UPSTREAM = (process.env.HIGHCHARTS_UPSTREAM_URL || "http://127.0.0.1:7801").replace(/\/$/, "");
178
- var PORT = Number(process.env.PORT || 1e4);
179
- var PROXY_TIMEOUT_MS = Number(process.env.PROXY_TIMEOUT_MS || 3e4);
180
- var HEALTH_TIMEOUT_MS = 2e3;
181
- var app = new Hono();
715
+ var DEFAULT_UPSTREAM = "http://127.0.0.1:7801";
716
+ var DEFAULT_PORT = 1e4;
717
+ var DEFAULT_PROXY_TIMEOUT_MS = 3e4;
718
+ var DEFAULT_HEALTH_TIMEOUT_MS = 2e3;
719
+ var DEFAULT_EXPORT_BODY_BYTES = 4 * 1024 * 1024;
720
+ var DEFAULT_RASTERIZE_BODY_BYTES = 32 * 1024 * 1024;
721
+ var DEFAULT_RESPONSE_BYTES = 24 * 1024 * 1024;
722
+ var MAX_CHART_DIMENSION = 4096;
723
+ var MAX_CHART_PIXELS = 16e6;
724
+ var MAX_CHART_SCALE = 4;
725
+ var UpstreamResponseTooLargeError = class extends Error {
726
+ };
727
+ function positiveInteger2(value, fallback) {
728
+ const parsed = Number.parseInt(value ?? "", 10);
729
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
730
+ }
731
+ function isHardenedEnv() {
732
+ const nodeEnv = process.env.NODE_ENV;
733
+ return nodeEnv !== void 0 && nodeEnv !== "development" && nodeEnv !== "test";
734
+ }
735
+ function envAuthOptions() {
736
+ const production = isHardenedEnv();
737
+ const requestedMode = process.env.RENDER_AUTH_MODE;
738
+ const mode = requestedMode === "auto" || requestedMode === "required" || requestedMode === "disabled" ? requestedMode : production ? "required" : "auto";
739
+ return {
740
+ mode,
741
+ apiKey: process.env.RENDER_API_KEY || process.env.API_KEY,
742
+ headerName: process.env.RENDER_API_KEY_HEADER || "x-api-key"
743
+ };
744
+ }
745
+ function envSourcePolicy() {
746
+ const requestedMode = process.env.OUTBOUND_SOURCE_MODE;
747
+ const mode = requestedMode === "safe" || requestedMode === "development" ? requestedMode : isHardenedEnv() ? "safe" : "development";
748
+ return {
749
+ mode,
750
+ allowedHosts: (process.env.OUTBOUND_HOST_ALLOWLIST || "").split(",").map((host) => host.trim().toLowerCase()).filter(Boolean)
751
+ };
752
+ }
753
+ function isRecord2(value) {
754
+ return typeof value === "object" && value !== null && !Array.isArray(value);
755
+ }
756
+ function resolveDimension(exporting, chart, sourceKey, chartKey, fallback) {
757
+ for (const candidate of [exporting?.[sourceKey], chart?.[chartKey]]) {
758
+ if (candidate === void 0) continue;
759
+ if (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate <= 0 || candidate > MAX_CHART_DIMENSION) {
760
+ throw new HTTPException4(400, {
761
+ message: "Requested chart dimensions are too large"
762
+ });
763
+ }
764
+ return candidate;
765
+ }
766
+ return fallback;
767
+ }
768
+ function resolveScale(value, fallback) {
769
+ if (value === void 0) return fallback;
770
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_CHART_SCALE) {
771
+ throw new HTTPException4(400, {
772
+ message: `scale must be between 0 and ${MAX_CHART_SCALE}`
773
+ });
774
+ }
775
+ return value;
776
+ }
777
+ function validateExportRequest(value) {
778
+ if (!isRecord2(value)) {
779
+ throw new HTTPException4(400, { message: "Export body must be an object" });
780
+ }
781
+ const allowedKeys = /* @__PURE__ */ new Set(["infile", "type", "b64", "scale", "resources"]);
782
+ const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
783
+ if (unknown.length > 0) {
784
+ throw new HTTPException4(400, {
785
+ message: `Unsupported export field: ${unknown[0]}`
786
+ });
787
+ }
788
+ if (!isRecord2(value.infile)) {
789
+ throw new HTTPException4(400, {
790
+ message: "Export infile must be a chart options object"
791
+ });
792
+ }
793
+ if (value.type !== "png" || value.b64 !== true) {
794
+ throw new HTTPException4(400, {
795
+ message: "Only base64 PNG exports are allowed"
796
+ });
797
+ }
798
+ const chart = isRecord2(value.infile.chart) ? value.infile.chart : void 0;
799
+ const exporting = isRecord2(value.infile.exporting) ? value.infile.exporting : void 0;
800
+ const scale = resolveScale(value.scale, resolveScale(exporting?.scale, 1));
801
+ const width = resolveDimension(exporting, chart, "sourceWidth", "width", 600);
802
+ const height = resolveDimension(
803
+ exporting,
804
+ chart,
805
+ "sourceHeight",
806
+ "height",
807
+ 400
808
+ );
809
+ if (width * height * scale * scale > MAX_CHART_PIXELS) {
810
+ throw new HTTPException4(400, {
811
+ message: "Requested chart dimensions are too large"
812
+ });
813
+ }
814
+ }
182
815
  function isTimeout(error) {
183
816
  const name = error?.name;
184
817
  return name === "TimeoutError" || name === "AbortError";
185
818
  }
186
- app.get("/health", async (c) => {
187
- try {
188
- const res = await fetch(`${UPSTREAM}/health`, {
189
- signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
190
- });
191
- if (res.ok) return c.text("ok");
192
- return c.json({ status: "degraded", upstream: res.status }, 503);
193
- } catch {
194
- return c.json({ status: "degraded", upstream: "unreachable" }, 503);
819
+ async function readLimitedBody(response, maxBytes) {
820
+ const declaredLength = Number(response.headers.get("content-length"));
821
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
822
+ await response.body?.cancel();
823
+ throw new UpstreamResponseTooLargeError();
195
824
  }
196
- });
197
- registerRasterizeRoute(app, {
198
- preMiddleware: [
199
- rateLimiter({
200
- limit: process.env.NODE_ENV === "production" ? 30 : 1e3,
201
- window: 15 * 60 * 1e3
202
- })
203
- ],
204
- onError: (error) => (
205
- // eslint-disable-next-line no-console
206
- console.error(
207
- "[jto-render-server] rasterize failed:",
208
- error instanceof Error ? error.message : error
209
- )
210
- )
211
- });
212
- app.all("*", async (c) => {
213
- const url = new URL(c.req.url);
214
- const target = `${UPSTREAM}${url.pathname}${url.search}`;
215
- const reqHeaders = new Headers(c.req.raw.headers);
216
- reqHeaders.delete("host");
217
- reqHeaders.delete("connection");
218
- reqHeaders.delete("content-length");
219
- const init = {
220
- method: c.req.method,
221
- headers: reqHeaders,
222
- signal: AbortSignal.timeout(PROXY_TIMEOUT_MS)
223
- };
224
- if (c.req.method !== "GET" && c.req.method !== "HEAD") {
225
- init.body = await c.req.arrayBuffer();
825
+ if (!response.body) return new Uint8Array();
826
+ const reader = response.body.getReader();
827
+ const chunks = [];
828
+ let total = 0;
829
+ for (; ; ) {
830
+ const { value, done } = await reader.read();
831
+ if (done) break;
832
+ total += value.byteLength;
833
+ if (total > maxBytes) {
834
+ await reader.cancel();
835
+ throw new UpstreamResponseTooLargeError();
836
+ }
837
+ chunks.push(value);
226
838
  }
227
- let res;
228
- try {
229
- res = await fetch(target, init);
230
- } catch (error) {
231
- return isTimeout(error) ? c.json(
232
- {
233
- error: `Highcharts upstream timed out after ${PROXY_TIMEOUT_MS}ms`
234
- },
235
- 504
236
- ) : c.json(
237
- { error: `Highcharts upstream unreachable at ${UPSTREAM}` },
238
- 502
239
- );
839
+ const output = new Uint8Array(total);
840
+ let offset = 0;
841
+ for (const chunk of chunks) {
842
+ output.set(chunk, offset);
843
+ offset += chunk.byteLength;
240
844
  }
241
- const payload = await res.arrayBuffer();
242
- const headers = {};
243
- res.headers.forEach((value, key) => {
244
- if (key !== "content-encoding" && key !== "content-length" && key !== "transfer-encoding") {
245
- headers[key] = value;
845
+ return output;
846
+ }
847
+ function createRenderServerApp(options = {}) {
848
+ const production = isHardenedEnv();
849
+ const upstream = (options.upstreamUrl || process.env.HIGHCHARTS_UPSTREAM_URL || DEFAULT_UPSTREAM).replace(/\/$/, "");
850
+ const proxyTimeoutMs = options.proxyTimeoutMs ?? positiveInteger2(process.env.PROXY_TIMEOUT_MS, DEFAULT_PROXY_TIMEOUT_MS);
851
+ const healthTimeoutMs = options.healthTimeoutMs ?? positiveInteger2(process.env.HEALTH_TIMEOUT_MS, DEFAULT_HEALTH_TIMEOUT_MS);
852
+ const maxExportBodyBytes = options.maxExportBodyBytes ?? positiveInteger2(
853
+ process.env.MAX_EXPORT_BODY_SIZE,
854
+ DEFAULT_EXPORT_BODY_BYTES
855
+ );
856
+ const maxRasterizeBodyBytes = options.maxRasterizeBodyBytes ?? positiveInteger2(
857
+ process.env.MAX_RASTERIZE_BODY_SIZE,
858
+ DEFAULT_RASTERIZE_BODY_BYTES
859
+ );
860
+ const maxResponseBytes = options.maxResponseBytes ?? positiveInteger2(
861
+ process.env.MAX_RENDER_RESPONSE_SIZE,
862
+ DEFAULT_RESPONSE_BYTES
863
+ );
864
+ const maxConcurrent = options.maxConcurrent ?? positiveInteger2(process.env.MAX_CONCURRENT_RENDERS, production ? 4 : 16);
865
+ const windowMs = options.rateLimitWindowMs ?? positiveInteger2(process.env.RENDER_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1e3);
866
+ const fetchImpl = options.fetch ?? fetch;
867
+ const sourcePolicy = options.sourcePolicy ?? envSourcePolicy();
868
+ const trustProxyHeaders = options.trustProxyHeaders ?? process.env.TRUST_PROXY_HEADERS === "true";
869
+ const app = new Hono();
870
+ const auth = createApiKeyAuthMiddleware(options.auth ?? envAuthOptions());
871
+ const capacity = concurrencyLimiter({ limit: maxConcurrent });
872
+ app.use("*", secureHeaders());
873
+ app.onError((error, c) => {
874
+ if (error instanceof HTTPException4) {
875
+ return c.json({ success: false, error: error.message }, error.status);
246
876
  }
877
+ return c.json({ success: false, error: "Internal server error" }, 500);
247
878
  });
248
- return c.body(payload, res.status, headers);
249
- });
250
- serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, (info) => {
251
- console.log(
252
- `[jto-render-server] listening on :${info.port} \u2014 POST /rasterize (local), proxy \u2192 ${UPSTREAM}`
879
+ app.get("/health", async (c) => {
880
+ try {
881
+ const response = await fetchImpl(`${upstream}/health`, {
882
+ signal: AbortSignal.timeout(healthTimeoutMs)
883
+ });
884
+ if (response.ok) return c.text("ok");
885
+ return c.json({ status: "degraded", upstream: response.status }, 503);
886
+ } catch {
887
+ return c.json({ status: "degraded", upstream: "unreachable" }, 503);
888
+ }
889
+ });
890
+ registerRasterizeRoute(app, {
891
+ getRasterizer: options.getRasterizer,
892
+ preMiddleware: [
893
+ rateLimiter({
894
+ limit: options.rasterizeRateLimit ?? positiveInteger2(
895
+ process.env.RASTERIZE_RATE_LIMIT,
896
+ production ? 30 : 1e3
897
+ ),
898
+ window: windowMs,
899
+ namespace: "rasterize",
900
+ trustProxy: trustProxyHeaders
901
+ }),
902
+ capacity,
903
+ bodyLimit2({
904
+ maxSize: maxRasterizeBodyBytes,
905
+ onError: () => {
906
+ throw new HTTPException4(413, { message: "Request body too large" });
907
+ }
908
+ }),
909
+ auth
910
+ ],
911
+ sourcePolicy
912
+ });
913
+ app.post(
914
+ "/export",
915
+ rateLimiter({
916
+ limit: options.exportRateLimit ?? positiveInteger2(process.env.EXPORT_RATE_LIMIT, production ? 60 : 1e3),
917
+ window: windowMs,
918
+ namespace: "export",
919
+ trustProxy: trustProxyHeaders
920
+ }),
921
+ capacity,
922
+ bodyLimit2({
923
+ maxSize: maxExportBodyBytes,
924
+ onError: () => {
925
+ throw new HTTPException4(413, { message: "Request body too large" });
926
+ }
927
+ }),
928
+ auth,
929
+ async (c) => {
930
+ const contentType = c.req.header("content-type");
931
+ if (!contentType?.toLowerCase().includes("application/json")) {
932
+ throw new HTTPException4(415, {
933
+ message: "Content-Type must be application/json"
934
+ });
935
+ }
936
+ let body;
937
+ try {
938
+ body = await c.req.json();
939
+ } catch {
940
+ throw new HTTPException4(400, { message: "Invalid JSON body" });
941
+ }
942
+ validateExportRequest(body);
943
+ try {
944
+ assertSafeRendererPayload(body, sourcePolicy);
945
+ } catch (error) {
946
+ if (error instanceof UnsafeOutboundSourceError) {
947
+ throw new HTTPException4(400, { message: error.message });
948
+ }
949
+ throw error;
950
+ }
951
+ let response;
952
+ try {
953
+ response = await fetchImpl(`${upstream}/export`, {
954
+ method: "POST",
955
+ headers: {
956
+ "Content-Type": "application/json",
957
+ Accept: "text/plain, image/png, application/json"
958
+ },
959
+ body: JSON.stringify(body),
960
+ signal: AbortSignal.timeout(proxyTimeoutMs)
961
+ });
962
+ } catch (error) {
963
+ return isTimeout(error) ? c.json(
964
+ {
965
+ error: `Highcharts upstream timed out after ${proxyTimeoutMs}ms`
966
+ },
967
+ 504
968
+ ) : c.json({ error: "Highcharts upstream unreachable" }, 502);
969
+ }
970
+ let payload;
971
+ try {
972
+ payload = await readLimitedBody(response, maxResponseBytes);
973
+ } catch (error) {
974
+ if (error instanceof UpstreamResponseTooLargeError) {
975
+ return c.json({ error: "Highcharts response too large" }, 502);
976
+ }
977
+ if (isTimeout(error)) {
978
+ return c.json(
979
+ {
980
+ error: `Highcharts upstream timed out after ${proxyTimeoutMs}ms`
981
+ },
982
+ 504
983
+ );
984
+ }
985
+ return c.json({ error: "Failed to read Highcharts response" }, 502);
986
+ }
987
+ const headers = {};
988
+ for (const name of [
989
+ "content-type",
990
+ "content-disposition",
991
+ "cache-control"
992
+ ]) {
993
+ const value = response.headers.get(name);
994
+ if (value) headers[name] = value;
995
+ }
996
+ return c.body(payload, response.status, headers);
997
+ }
253
998
  );
254
- });
999
+ for (const route of ["/export", "/rasterize"]) {
1000
+ app.all(route, (c) => {
1001
+ c.header("Allow", "POST");
1002
+ return c.json({ success: false, error: "Method not allowed" }, 405);
1003
+ });
1004
+ }
1005
+ app.notFound((c) => c.json({ success: false, error: "Not found" }, 404));
1006
+ return app;
1007
+ }
1008
+ function isMainModule() {
1009
+ if (!process.argv[1]) return false;
1010
+ try {
1011
+ return resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
1012
+ } catch {
1013
+ return false;
1014
+ }
1015
+ }
1016
+ if (isMainModule()) {
1017
+ const port = positiveInteger2(process.env.PORT, DEFAULT_PORT);
1018
+ const app = createRenderServerApp();
1019
+ void import("@hono/node-server").then(({ serve }) => {
1020
+ serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }, (info) => {
1021
+ console.log(`[jto-render-server] listening on :${info.port}`);
1022
+ });
1023
+ });
1024
+ }
1025
+ export {
1026
+ createRenderServerApp
1027
+ };
255
1028
  //# sourceMappingURL=render-server.js.map