Skip to content

fix(ui): render templates from the path checked against the emails directory - #3840

Closed
dielduarte wants to merge 2 commits into
canaryfrom
fix/ui-resolve-email-path-once
Closed

dielduarte wants to merge 2 commits into
canaryfrom
fix/ui-resolve-email-path-once

Conversation

@dielduarte

@dielduarte dielduarte commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes code scanning alert #327 (js/path-injection).

renderEmailByPath is a server action, so it receives arbitrary strings from the client. It checked them with isPathWithinEmailsDirectory, which resolves a relative path against the emails directory, but then used the original string, which getEmailComponent and fs.readFile resolve against the working directory. A relative path like welcome.tsx passed the check as <emails>/welcome.tsx and was loaded from <cwd>/welcome.tsx.

Our own pages always pass absolute paths, and server actions only accept same-origin calls, so this needed local access to the preview server. The fix makes the checked path the only one that gets used.

Changes

  • utils/is-path-within-emails-directory.ts → utils/resolve-within-emails-directory.ts: isPathWithinEmailsDirectory(path): boolean becomes resolveWithinEmailsDirectory(path): string | undefined, returning the real path it checked (or undefined outside the emails directory). The check is a single startsWith(root + sep) on the real path, the pattern CodeQL recognizes as a guard; an earlier version that also accepted the emails directory itself (=== root || startsWith) kept the alert open. Rendering the directory itself was never meaningful, so it's now rejected too. Otherwise the same rules: traversal, absolute paths, sibling prefixes and escaping symlinks are rejected, and it fails closed without the env var.
  • actions/render-email-by-path.tsx: renderEmailByPath and invalidateEmailRenderingCache resolve once and use only the resolved path from then on (rendering, cache keys).
  • actions/get-email-path-from-slug.ts: uses the new function; still returns the absolute candidate path.
  • .changeset/ui-resolve-email-path-once.md: patch for @react-email/ui.

Testing

  • New tests, written first and failing on canary:
    • renderEmailByPath('raw-html-email.html') renders the template from the emails directory.
    • renderEmailByPath('vercel-invite-user.tsx') bundles and renders the template from the emails directory (the getEmailComponent path CodeQL flagged).
  • The guard's spec is ported to the resolver: it now asserts the returned path, plus every existing rejection case, and that the emails directory itself is rejected.
  • CodeQL on this PR: no new alerts in changed code, so feat(render): Export options #327 closes on merge.
  • Suites: ui 115/115, react-email 319/319, render 90/90, editor 540/540. pnpm build, pnpm typecheck and biome pass.
  • Preview server: email dev on apps/demo renders all 62 templates with HTML identical to before this change, and hot reload (which goes through invalidateEmailRenderingCache) still updates the preview.

Summary by cubic

Fixes a path-injection vulnerability in @react-email/ui where a relative template path passed the emails-directory check but was then resolved against the working directory, reading files outside the emails directory.

renderEmailByPath and invalidateEmailRenderingCache resolve the requested path once, against the emails directory, and use only that resolved path for rendering and cache invalidation.

  • Replaces isPathWithinEmailsDirectory with resolveWithinEmailsDirectory, which returns the real path it validated (or undefined outside the directory) so callers can't read a different file.
  • Adds tests that fail on the previous behavior for raw HTML and bundled .tsx templates.

Written for commit 34f86fc. Summary will update on new commits.

View guided diff

@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
react-email Ready Ready Preview Oct 29, 2026 2:29pm UTC
react-email-demo Ready Ready Preview Oct 29, 2026 2:29pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 34f86fc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@react-email/ui Patch
react-email Patch
@react-email/editor Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@react-email/ui@3840

commit: 34f86fc

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 7 files

Confidence score: 3/5

  • In resolve-within-emails-directory.ts, a missing target falls back to its lexical path, so an existing symlink parent can bypass the directory check and let a later render reach a file outside the emails directory. Resolve existing parent components before accepting the path.
  • In render-email-by-path.tsx, symlinked templates use the target path for the preview title and render-mode branch, which can make the preview behave differently from the requested template. Derive these values from the requested path.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/ui/src/utils/resolve-within-emails-directory.ts">

<violation number="1" location="packages/ui/src/utils/resolve-within-emails-directory.ts:8">
P1: `safeRealpath` falls back to the lexical path when the target is missing, so existing symlink parents are not checked. A file created through such a parent after validation can then be rendered outside the emails directory; resolve the nearest existing parent before checking, or fail closed when the target cannot be canonicalized.</violation>
</file>

<file name="packages/ui/src/actions/render-email-by-path.tsx">

<violation number="1" location="packages/ui/src/actions/render-email-by-path.tsx:79">
P2: `renderEmailByPath` now derives `basename`, `extname`, and the render-mode branch from the symlink target's real path, not the requested template path. For a symlinked template the preview title (page.tsx) and the metadata `basename` can disagree, and a target with a different extension changes the render mode. If this is intended, keep the requested path for display and only use the real path for file access.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

try {
return fs.realpathSync(target);
} catch {
return path.resolve(target);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: safeRealpath falls back to the lexical path when the target is missing, so existing symlink parents are not checked. A file created through such a parent after validation can then be rendered outside the emails directory; resolve the nearest existing parent before checking, or fail closed when the target cannot be canonicalized.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/ui/src/utils/resolve-within-emails-directory.ts, line 8:

<comment>`safeRealpath` falls back to the lexical path when the target is missing, so existing symlink parents are not checked. A file created through such a parent after validation can then be rendered outside the emails directory; resolve the nearest existing parent before checking, or fail closed when the target cannot be canonicalized.</comment>

<file context>
@@ -0,0 +1,40 @@
+  try {
+    return fs.realpathSync(target);
+  } catch {
+    return path.resolve(target);
+  }
+};
</file context>

export const invalidateEmailRenderingCache = async (
requestedEmailPath: string,
) => {
const emailPath = resolveWithinEmailsDirectory(requestedEmailPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: renderEmailByPath now derives basename, extname, and the render-mode branch from the symlink target's real path, not the requested template path. For a symlinked template the preview title (page.tsx) and the metadata basename can disagree, and a target with a different extension changes the render mode. If this is intended, keep the requested path for display and only use the real path for file access.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/ui/src/actions/render-email-by-path.tsx, line 79:

<comment>`renderEmailByPath` now derives `basename`, `extname`, and the render-mode branch from the symlink target's real path, not the requested template path. For a symlinked template the preview title (page.tsx) and the metadata `basename` can disagree, and a target with a different extension changes the render mode. If this is intended, keep the requested path for display and only use the real path for file access.</comment>

<file context>
@@ -73,8 +73,11 @@ const invalidateCacheFor = (emailPath: string) => {
+export const invalidateEmailRenderingCache = async (
+  requestedEmailPath: string,
+) => {
+  const emailPath = resolveWithinEmailsDirectory(requestedEmailPath);
+  if (!emailPath) return;
   invalidateCacheFor(emailPath);
</file context>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

View guided diff | Re-trigger cubic

@dielduarte

Copy link
Copy Markdown
Contributor Author

Closing: the path check mismatch is only reachable through the local preview server's same-origin server action, so we're accepting the risk and dismissing the alert instead.

@dielduarte dielduarte closed this Oct 29, 2026

This branch was successfully deployed

2 active deployments
Preview – react-email-demo — 34f86fcf Deployed Oct 29, 2026 by vercel[bot]
Preview – react-email — 34f86fcf Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant