DEV Community

xiaoxu
xiaoxu

Posted on

Keeping Markdown Image Uploads Inside the Article Directory

Keeping Markdown Image Uploads Inside the Article Directory

Why this matters

Turning ![diagram](./images/diagram.png) into a hosted URL looks like a small Markdown transform. But the transform crosses an important trust boundary: a Markdown author controls the image string, while the publisher later reads a local file and may upload it with long-lived credentials.

If that string can name an arbitrary local file, the publisher can read outside the article workspace. If it can be an external or data: URL, the meaning of “upload this article asset” becomes unclear and the resulting post may include content the pipeline never controlled.

This article documents the boundary used in a TypeScript Markdown publisher. It is deliberately narrow: only local, relative image paths that resolve inside the article directory may reach the upload callback.

What I built or tested

I exercised replaceMarkdownImages from this repository against one allowed input and six disallowed inputs. The callback was a local test double, so the experiment did not contact image storage or a publishing API.

Input Result
./images/diagram.png Accepted and replaced through the upload callback
../secret.png Rejected as an article-directory escape
/tmp/secret.png Rejected as an absolute path
https://example.com/x.png Rejected as an external URL
data:image/png;base64,AA Rejected as a data URL
./images/a.png?size=1 Rejected because query strings are not local file paths
./images/a.png#caption Rejected because fragments are not local file paths

The focused Vitest suite also passed: its traversal test confirms that ../secret.png is rejected, and its deduplication test confirms that two Markdown references to the same local path share one upload operation.

Setup

The implementation has one useful constraint: the safety check belongs in the code that resolves a Markdown image node, not only in a command-line wrapper. That keeps the same rule in force for normal publishing, image-only preparation, and dry-run payload generation.

At a high level, the resolver needs an article directory and an image URL. Its contract is simple:

accept only a relative local path that resolves beneath articleDirectory
Enter fullscreen mode Exit fullscreen mode

The upload layer can then assume it received a file that belongs to the article. It should still check that the path exists and is a file before optimizing or uploading it.

Step-by-step walkthrough

The pipeline has four gates before it hands a path to the uploader:

Mermaid diagram 1

The order matters: the URL-style check rejects https: and data: before filesystem resolution. The local resolver then rejects absolute paths as well as paths containing a query or fragment. For the remaining candidates, it decodes the path, resolves it relative to the article directory, and compares the result back to that directory. Any .. escape is rejected.

Only after those checks does the Markdown traversal call the upload callback. It keeps a map keyed by the resolved absolute source path, so repeated inline images do not trigger two concurrent uploads.

Here is a reusable review checklist for this boundary:

  1. Reject http:, https:, and data: image URLs explicitly.
  2. Reject absolute paths, query strings, and fragments before path resolution.
  3. Decode before containment validation so encoded traversal is not treated as harmless text.
  4. Resolve from the article directory, then verify the resulting path is still inside it.
  5. Verify that the resolved object is a file at the upload boundary.
  6. Key upload de-duplication on the validated resolved path, not the original Markdown spelling.

What went wrong

The failure case is not a hypothetical parser oddity. ../secret.png is a relative-looking Markdown reference, but resolving it from an article directory points outside that directory. Treating “not absolute” as the full security check would let that path reach the file reader.

There is a second, less obvious constraint: a URL query string or fragment is meaningful to a browser, but not to a local file resolver. Silently stripping it would create an ambiguous contract; treating it as part of the file name makes a missing-file failure likely. This publisher rejects those forms instead, so article sources have one canonical local-asset syntax.

Fix or mitigation

The mitigation is a layered allowlist rather than a blacklist of suspicious filenames:

  • The Markdown walker rejects external and data URLs.
  • The resolver allows only relative local strings without query strings or fragments.
  • The containment test runs after decoding and resolution.
  • The image processor verifies file existence and file type before it reads or optimizes anything.

That sequence makes the policy visible and testable. It is also easier to reason about than allowing every URL shape and trying to identify the dangerous exceptions later.

Trade-offs

This contract intentionally does not support remote Markdown images, inline data images, or local asset URLs with cache-busting query strings. Authors must copy assets into the article directory and reference them with plain relative paths.

That is a usability cost, especially when migrating existing Markdown. In return, the source article is portable, the publish input is deterministic, and the uploader has a small, auditable file-read surface. If a product truly needs external images, add a separate, explicit remote-fetch feature with its own allowlist, size limits, content-type validation, and network policy; do not smuggle it through the local-asset path.

How I verified it

I ran the public replacement function with a test callback and recorded the allowed and rejected inputs above. I then ran npm test -- test/markdown.test.ts; the focused suite passed both tests. The evidence record also ties each claim to the resolver and traversal source locations.

For a change to this boundary, I would add one focused test per newly accepted syntax and retain the negative cases. A permissive regression here is more significant than a cosmetic Markdown change because it changes which local files a publishing credentialed process may read.

Conclusion

Safe Markdown image handling starts with a narrow contract: local, relative assets contained by the article directory. Validate that contract before upload, test the rejected shapes as seriously as the happy path, and make any broader capability a distinct feature with its own safeguards.

Top comments (0)