h17-sspdf 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -293,7 +293,7 @@ Any operation accepts `xMm` and `maxWidthMm` to override the theme margins for t
293
293
  fontSize: 10, // pt
294
294
  color: [0, 0, 0], // RGB
295
295
  lineHeight: 1.4, // multiplier
296
- textTransform: 'uppercase', // 'uppercase' | 'lowercase' | undefined
296
+ textTransform: 'upper', // 'upper' | 'lower' | undefined
297
297
 
298
298
  // Spacing
299
299
  marginTopMm: 0,
@@ -468,6 +468,18 @@ Claude Code skills for generating PDFs and themes are available in the `skills/`
468
468
 
469
469
  ---
470
470
 
471
+ ## Security model
472
+
473
+ SuperSimplePDF turns data into a document. The split between data and code matters for how you treat each input.
474
+
475
+ - Source JSON is data. You can generate it from untrusted input. The engine reads it as a description of what to render, never as code.
476
+ - Theme files are code. A `.js` theme is loaded with `require`, so it runs with your process privileges. Treat a theme like any other module you import. Do not load theme files you do not trust. Built-in themes referenced by name are safe.
477
+ - Image paths are contained. The `image` operation reads files from disk via `src`. To stop an untrusted source document from reading arbitrary files into a PDF, `src` must be a relative path that stays inside the working directory. Absolute paths, parent directory traversal with `..`, and null bytes are rejected.
478
+
479
+ The engine has no network access and no runtime dependencies in the base install, so a source document cannot trigger an outbound request. The optional chart plugin adds the `canvas` native module, which renders locally and does not phone home.
480
+
481
+ If you find a security issue, see SECURITY.md.
482
+
471
483
  ## Constraints
472
484
 
473
485
  - Page format defaults to A4; custom dimensions supported via `pageWidthMm`/`pageHeightMm` (e.g. 16:9 presentations)
@@ -1,5 +1,10 @@
1
1
  "use strict";
2
2
 
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024;
7
+
3
8
  /**
4
9
  * Read native width/height from PNG or JPEG buffer.
5
10
  * Returns { width, height, format } or throws.
@@ -63,6 +68,62 @@ function getImageDimensions(buf) {
63
68
  throw new Error("Unsupported image format. Only PNG and JPEG are supported.");
64
69
  }
65
70
 
71
+ /**
72
+ * Resolve and validate an image `src` path.
73
+ * Only relative paths inside the current working directory are allowed.
74
+ * Returns the absolute resolved path.
75
+ *
76
+ * @param {string} src - Relative path from source JSON
77
+ * @param {string} [index] - Operation index for error messages
78
+ * @returns {string}
79
+ */
80
+ function resolveImagePath(src, index) {
81
+ const prefix = index ? `Operation ${index} (image) ` : "image ";
82
+ if (!src || typeof src !== "string") {
83
+ throw new Error(`${prefix}missing "src" field`);
84
+ }
85
+ if (path.isAbsolute(src) || src.includes("\0")) {
86
+ throw new Error(`${prefix}"src" must be a relative path: "${src}"`);
87
+ }
88
+ const imageBaseDir = path.resolve(process.cwd());
89
+ const resolvedPath = path.resolve(imageBaseDir, src);
90
+ if (resolvedPath !== imageBaseDir && !resolvedPath.startsWith(imageBaseDir + path.sep)) {
91
+ throw new Error(`${prefix}"src" escapes the working directory: "${src}"`);
92
+ }
93
+ return resolvedPath;
94
+ }
95
+
96
+ /**
97
+ * Read an image file with size limits.
98
+ *
99
+ * @param {string} resolvedPath
100
+ * @param {string} [index]
101
+ * @param {number} [maxSizeBytes]
102
+ * @returns {Buffer}
103
+ */
104
+ function readImageFile(resolvedPath, index, maxSizeBytes = MAX_IMAGE_SIZE_BYTES) {
105
+ const prefix = index ? `Operation ${index} (image) ` : "image ";
106
+ let stats;
107
+ try {
108
+ stats = fs.statSync(resolvedPath);
109
+ } catch (err) {
110
+ throw new Error(`${prefix}could not stat file "${resolvedPath}": ${err.message}`);
111
+ }
112
+ if (stats.size > maxSizeBytes) {
113
+ throw new Error(`${prefix}file exceeds ${maxSizeBytes} bytes: "${resolvedPath}"`);
114
+ }
115
+ let buf;
116
+ try {
117
+ buf = fs.readFileSync(resolvedPath);
118
+ } catch (err) {
119
+ throw new Error(`${prefix}could not read file "${resolvedPath}": ${err.message}`);
120
+ }
121
+ if (!buf || buf.length === 0) {
122
+ throw new Error(`${prefix}file is empty: "${resolvedPath}"`);
123
+ }
124
+ return buf;
125
+ }
126
+
66
127
  /**
67
128
  * Resolve display dimensions from source operation and content bounds.
68
129
  *
@@ -109,4 +170,10 @@ function resolveImageSize(op, nativeW, nativeH, contentWidthMm) {
109
170
  return { widthMm: contentWidthMm, heightMm: contentWidthMm * ratio };
110
171
  }
111
172
 
112
- module.exports = { getImageDimensions, resolveImageSize };
173
+ module.exports = {
174
+ getImageDimensions,
175
+ resolveImageSize,
176
+ resolveImagePath,
177
+ readImageFile,
178
+ MAX_IMAGE_SIZE_BYTES,
179
+ };
package/core/pdf-core.js CHANGED
@@ -461,12 +461,12 @@ class PDFCore {
461
461
  );
462
462
  const baseline = y + baselineOffsetMm;
463
463
 
464
- if (payload.leftText) {
464
+ if (payload.leftText != null) {
465
465
  this.applyTextStyle(leftStyle);
466
466
  this.doc.text(applyTextTransform(String(payload.leftText), leftStyle.textTransform), xLeft, baseline);
467
467
  }
468
468
 
469
- if (payload.rightText) {
469
+ if (payload.rightText != null) {
470
470
  this.applyTextStyle(rightStyle);
471
471
  this.doc.text(applyTextTransform(String(payload.rightText), rightStyle.textTransform), xRight, baseline, { align: "right" });
472
472
  }
@@ -967,7 +967,10 @@ class PDFCore {
967
967
 
968
968
  _resolveColor(color, fallback) {
969
969
  if (Array.isArray(color) && color.length === 3) {
970
- return color;
970
+ return color.map((c) => {
971
+ const n = Number(c) || 0;
972
+ return Math.max(0, Math.min(255, Math.round(n)));
973
+ });
971
974
  }
972
975
  return fallback;
973
976
  }
@@ -1,11 +1,9 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
1
  const { PDFCore, getStyleMarginsMm, getTextPaddingMm, applyTextTransform } = require("./pdf-core");
4
2
  const { pxToMm, resolveLineHeightMm } = require("./units");
5
3
  const { registerThemeFonts } = require("./font-registry");
6
4
  const { getPlugin, hasPlugin } = require("./plugin-registry");
7
5
  const { validateSource, validateTheme, validateSourceAgainstTheme } = require("./validate");
8
- const { getImageDimensions, resolveImageSize } = require("./image-utils");
6
+ const { getImageDimensions, resolveImageSize, resolveImagePath, readImageFile } = require("./image-utils");
9
7
 
10
8
  /**
11
9
  * Render a document by executing labeled operations.
@@ -754,31 +752,11 @@ function executeOperation(ctx) {
754
752
  const bounds = getHorizontalBounds(core, templateBypassMargins);
755
753
  const contentWidthMm = bounds.right - bounds.left;
756
754
 
757
- // Read image file
758
- const srcPath = operation.src;
759
- if (!srcPath) {
760
- throw new Error(`Operation ${index} (image) missing "src" field`);
761
- }
762
- // Containment: src must be a relative path under the working directory.
763
- // Blocks absolute paths and ../ traversal so an untrusted source document
764
- // cannot read arbitrary files (e.g. /etc/passwd, ../../app/.env) into the PDF.
765
- if (path.isAbsolute(srcPath) || srcPath.includes("\0")) {
766
- throw new Error(`Operation ${index} (image) "src" must be a relative path: "${srcPath}"`);
767
- }
768
- const imageBaseDir = path.resolve(process.cwd());
769
- const resolvedPath = path.resolve(imageBaseDir, srcPath);
770
- if (resolvedPath !== imageBaseDir && !resolvedPath.startsWith(imageBaseDir + path.sep)) {
771
- throw new Error(`Operation ${index} (image) "src" escapes the working directory: "${srcPath}"`);
772
- }
773
- let buf;
774
- try {
775
- buf = fs.readFileSync(resolvedPath);
776
- } catch (err) {
777
- throw new Error(`Operation ${index} (image) could not read file "${resolvedPath}": ${err.message}`);
778
- }
779
- if (!buf || buf.length === 0) {
780
- throw new Error(`Operation ${index} (image) file is empty: "${resolvedPath}"`);
781
- }
755
+ // Read image file with containment and size limits.
756
+ // Containment blocks absolute paths and ../ traversal so an untrusted source
757
+ // document cannot read arbitrary files (e.g. /etc/passwd, ../../app/.env).
758
+ const resolvedPath = resolveImagePath(operation.src, index);
759
+ const buf = readImageFile(resolvedPath, index);
782
760
  let imgInfo;
783
761
  try {
784
762
  imgInfo = getImageDimensions(buf);
@@ -1145,15 +1123,8 @@ function estimateOperationHeight(ctx) {
1145
1123
  let imgHeightMm = 80; // fallback
1146
1124
  if (operation.src) {
1147
1125
  try {
1148
- if (path.isAbsolute(operation.src) || operation.src.includes("\0")) {
1149
- throw new Error(`image "src" must be a relative path: "${operation.src}"`);
1150
- }
1151
- const imageBaseDir = path.resolve(process.cwd());
1152
- const resolvedPath = path.resolve(imageBaseDir, operation.src);
1153
- if (resolvedPath !== imageBaseDir && !resolvedPath.startsWith(imageBaseDir + path.sep)) {
1154
- throw new Error(`image "src" escapes the working directory: "${operation.src}"`);
1155
- }
1156
- const buf = fs.readFileSync(resolvedPath);
1126
+ const resolvedPath = resolveImagePath(operation.src, index);
1127
+ const buf = readImageFile(resolvedPath, index);
1157
1128
  const imgInfo = getImageDimensions(buf);
1158
1129
  const resolved = resolveImageSize(operation, imgInfo.width, imgInfo.height, contentWidthMm - padding.left - padding.right);
1159
1130
  imgHeightMm = resolved.heightMm;
package/core/shapes.js CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  'use strict';
17
17
 
18
- const PT_TO_MM = 0.3528;
18
+ const { PT_TO_MM } = require('./units');
19
19
 
20
20
  // Vertical center: when pt is given, use half the font height; otherwise fall back to 1*s
21
21
  function mid(y, s, pt) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "h17-sspdf",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Declarative PDF engine - define layout once, feed it JSON, get consistent PDFs",
5
5
  "main": "index.js",
6
6
  "author": "Hugo Palma",