diff --git a/changelog.d/capy-gst-1747-make-pdf-local-images.md b/changelog.d/capy-gst-1747-make-pdf-local-images.md
new file mode 100644
index 000000000..4b50f72b6
--- /dev/null
+++ b/changelog.d/capy-gst-1747-make-pdf-local-images.md
@@ -0,0 +1,4 @@
+---
+important: security
+---
+- **make-pdf reads local images only from the markdown's own directory, and only image files.** An image reference that resolves outside the input markdown's directory (a `../` path, an absolute path, a `file:` URL or a symlink pointing out) is no longer read: it renders as a visible `[image blocked (outside the document directory): …]` placeholder with a warning, and `--strict` still fails the run. A local file that is not a png, jpeg, gif, webp or svg image renders as an `[image blocked (not an image): …]` placeholder instead of being inlined, and fails the run under `--strict`. The make-pdf skill and docs/howto-diagrams-and-formats.md describe the new defaults. (GST-1747)
diff --git a/docs/howto-diagrams-and-formats.md b/docs/howto-diagrams-and-formats.md
index fa31cf240..788e1222e 100644
--- a/docs/howto-diagrams-and-formats.md
+++ b/docs/howto-diagrams-and-formats.md
@@ -59,9 +59,11 @@ so a phone photo doesn't bloat the document.
Image safety defaults: remote (http/https) images are **blocked with a
visible placeholder** unless you pass `--allow-network`. An image path that
-resolves outside the markdown's directory (even through a symlink) still
-inlines but warns loudly. Files over 64MB and non-regular files (fifos,
-devices) degrade to a placeholder instead of hanging the render.
+resolves outside the markdown's directory (even through a symlink) is never
+read: it renders as a visible placeholder with a warning. Only image files
+(png, jpeg, gif, webp, svg) inline; other files, files over 64MB and
+non-regular files (fifos, devices) degrade to a placeholder instead of
+hanging the render.
Per-image directives go immediately after the image:
@@ -130,9 +132,9 @@ make-pdf generate docs.md --strict
```
Missing local images, blocked remote images, out-of-tree image reads (a path
-or symlink resolving outside the markdown's directory), oversized files
-(>64MB), and non-regular files all exit non-zero instead of degrading to a
-warning or placeholder — for docs pipelines where a broken image should
+or symlink resolving outside the markdown's directory), non-image files,
+oversized files (>64MB), and non-regular files all exit non-zero instead of
+degrading to a warning or placeholder — for docs pipelines where a broken image should
break the build.
## Troubleshooting
diff --git a/make-pdf/SKILL.md b/make-pdf/SKILL.md
index 813061d1e..4c164a68d 100644
--- a/make-pdf/SKILL.md
+++ b/make-pdf/SKILL.md
@@ -285,10 +285,11 @@ small with no visible quality loss.
Remote (http/https) images are **blocked with a visible placeholder** by
default — offline posture; pass `--allow-network` to fetch them. An image
-that resolves outside the markdown's directory (even via symlink) still
-inlines, but warns loudly; `--strict` makes it fatal. Files over 64MB or
-non-regular files (fifos, devices) degrade to a placeholder instead of
-hanging the run.
+that resolves outside the markdown's directory (even via symlink) is never
+read: it renders as a visible placeholder with a warning, and `--strict`
+makes it fatal. Only image files (png, jpeg, gif, webp, svg) inline; other
+files, files over 64MB and non-regular files (fifos, devices) degrade to a
+placeholder instead of hanging the run.
Per-image directives, written immediately after the image:
@@ -322,8 +323,8 @@ promoted page is vertically centered. When the heuristic guesses wrong,
### CI mode — fail loud on missing assets
```bash
-"$P" generate docs.md --strict # missing, remote, out-of-tree, oversized,
- # and non-regular-file images exit non-zero
+"$P" generate docs.md --strict # missing, remote, out-of-tree, non-image,
+ # oversized and non-regular-file images exit non-zero
# instead of warn + placeholder
```
@@ -348,8 +349,9 @@ Branding:
Output:
--to pdf|html|docx Output format (default: pdf). html = single
self-contained file; docx = content fidelity.
- --strict Missing, remote, out-of-tree, oversized, or
- non-regular-file images fail the run (CI mode).
+ --strict Missing, remote, out-of-tree, non-image,
+ oversized, or non-regular-file images fail the
+ run (CI mode).
--page-numbers "N of M" footer (default on)
--tagged Accessible PDF (default on)
--outline PDF bookmarks from headings (default on)
diff --git a/make-pdf/SKILL.md.tmpl b/make-pdf/SKILL.md.tmpl
index b55cf768a..27bed9e7a 100644
--- a/make-pdf/SKILL.md.tmpl
+++ b/make-pdf/SKILL.md.tmpl
@@ -133,10 +133,11 @@ small with no visible quality loss.
Remote (http/https) images are **blocked with a visible placeholder** by
default — offline posture; pass `--allow-network` to fetch them. An image
-that resolves outside the markdown's directory (even via symlink) still
-inlines, but warns loudly; `--strict` makes it fatal. Files over 64MB or
-non-regular files (fifos, devices) degrade to a placeholder instead of
-hanging the run.
+that resolves outside the markdown's directory (even via symlink) is never
+read: it renders as a visible placeholder with a warning, and `--strict`
+makes it fatal. Only image files (png, jpeg, gif, webp, svg) inline; other
+files, files over 64MB and non-regular files (fifos, devices) degrade to a
+placeholder instead of hanging the run.
Per-image directives, written immediately after the image:
@@ -170,8 +171,8 @@ promoted page is vertically centered. When the heuristic guesses wrong,
### CI mode — fail loud on missing assets
```bash
-"$P" generate docs.md --strict # missing, remote, out-of-tree, oversized,
- # and non-regular-file images exit non-zero
+"$P" generate docs.md --strict # missing, remote, out-of-tree, non-image,
+ # oversized and non-regular-file images exit non-zero
# instead of warn + placeholder
```
@@ -196,8 +197,9 @@ Branding:
Output:
--to pdf|html|docx Output format (default: pdf). html = single
self-contained file; docx = content fidelity.
- --strict Missing, remote, out-of-tree, oversized, or
- non-regular-file images fail the run (CI mode).
+ --strict Missing, remote, out-of-tree, non-image,
+ oversized, or non-regular-file images fail the
+ run (CI mode).
--page-numbers "N of M" footer (default on)
--tagged Accessible PDF (default on)
--outline PDF bookmarks from headings (default on)
diff --git a/make-pdf/src/diagram-prepass.ts b/make-pdf/src/diagram-prepass.ts
index de84b6d38..b3e2c8e95 100644
--- a/make-pdf/src/diagram-prepass.ts
+++ b/make-pdf/src/diagram-prepass.ts
@@ -609,19 +609,20 @@ export async function inlineLocalImages(html: string, opts: PrepassImageOptions)
return buildMissingImagePlaceholder(src);
}
- // Out-of-tree reads are legal (local CLI semantics — like pandoc) but
- // never silent: an agent PDF-ing untrusted markdown should not quietly
- // embed ~/.ssh/config into a shareable document. --strict makes it fatal.
+ // Out-of-tree files are never read: rendering untrusted markdown must not
+ // copy files from elsewhere on disk into a shareable document. Visible
+ // placeholder by default; --strict makes it fatal.
// Compare REAL paths — a symlink inside the input dir pointing outside
// would otherwise pass a string-prefix check (Codex adversarial finding).
// Runs after the existence check: realpath of a missing file can't
// resolve, and on macOS /var vs /private/var would false-positive.
- const inputRoot = safeRealpath(path.resolve(opts.inputDir)) + path.sep;
+ const inputRoot = safeRealpath(path.resolve(opts.inputDir));
const realFilePath = safeRealpath(filePath);
- if (!realFilePath.startsWith(inputRoot)) {
+ if (!isInsideDir(realFilePath, inputRoot)) {
const msg = `image resolves OUTSIDE the input directory: ${src} → ${realFilePath}`;
- if (opts.strict) throw new StrictModeError(msg + " — move it under the markdown's directory or drop --strict");
+ if (opts.strict) throw new StrictModeError(msg + " — move it under the markdown's directory");
opts.warn(msg);
+ return buildBlockedLocalPlaceholder("outside the document directory", src);
}
// Bound the read BEFORE reading: a markdown image pointing at a special
@@ -649,7 +650,13 @@ export async function inlineLocalImages(html: string, opts: PrepassImageOptions)
const buf = fs.readFileSync(filePath);
const dims = imageDims(buf);
- const mime = dims?.mime ?? mimeFromExtension(filePath);
+ const mime = dims?.mime ?? signedExtensionMime(buf, filePath);
+ if (mime === "application/octet-stream") {
+ const msg = `not a recognized image (png, jpeg, gif, webp, svg): ${src}`;
+ if (opts.strict) throw new StrictModeError(msg);
+ opts.warn(msg);
+ return buildBlockedLocalPlaceholder("not an image", src);
+ }
// Print-resolution normalization (D4): rasters only — SVG scales free.
if (dims && mime !== "image/svg+xml" && dims.width > maxPx && opts.run) {
@@ -735,6 +742,18 @@ function buildBlockedRemotePlaceholder(src: string): string {
);
}
+function buildBlockedLocalPlaceholder(reason: string, src: string): string {
+ return (
+ `` +
+ `[image blocked (${reason}): ${escapeHtml(src)}]`
+ );
+}
+
+function isInsideDir(p: string, dir: string): boolean {
+ const rel = path.relative(dir, p);
+ return rel !== "" && rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel);
+}
+
/** realpath that degrades to the input path when resolution fails. */
function safeRealpath(p: string): string {
try {
@@ -744,6 +763,20 @@ function safeRealpath(p: string): string {
}
}
+const IMAGE_SIGNATURES: Record boolean> = {
+ "image/png": (b) => b.length >= 8 && b.readUInt32BE(0) === 0x89504e47 && b.readUInt32BE(4) === 0x0d0a1a0a,
+ "image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
+ "image/gif": (b) => /^GIF8[79]a/.test(b.toString("ascii", 0, 6)),
+ "image/webp": (b) => b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP",
+ "image/svg+xml": (b) => /