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

Draft
DI
1d ago
+101-61

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 #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.