From 30117a3c9c4b47ce498e1173dba1b06dd5af4a75 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Thu, 29 Oct 2026 10:19:13 -0400 Subject: [PATCH 1/2] fix(ui): render templates from the path checked against the emails directory --- .changeset/ui-resolve-email-path-once.md | 5 ++ .../src/actions/get-email-path-from-slug.ts | 4 +- .../src/actions/render-email-by-path.spec.ts | 22 +++++++++ .../ui/src/actions/render-email-by-path.tsx | 16 +++--- .../utils/is-path-within-emails-directory.ts | 31 ------------ ...> resolve-within-emails-directory.spec.ts} | 49 ++++++++++--------- .../utils/resolve-within-emails-directory.ts | 40 +++++++++++++++ 7 files changed, 106 insertions(+), 61 deletions(-) create mode 100644 .changeset/ui-resolve-email-path-once.md delete mode 100644 packages/ui/src/utils/is-path-within-emails-directory.ts rename packages/ui/src/utils/{is-path-within-emails-directory.spec.ts => resolve-within-emails-directory.spec.ts} (60%) create mode 100644 packages/ui/src/utils/resolve-within-emails-directory.ts diff --git a/.changeset/ui-resolve-email-path-once.md b/.changeset/ui-resolve-email-path-once.md new file mode 100644 index 0000000000..ac4d4acedd --- /dev/null +++ b/.changeset/ui-resolve-email-path-once.md @@ -0,0 +1,5 @@ +--- +"@react-email/ui": patch +--- + +Render templates from the path that was checked against the emails directory, so a relative path can no longer pass the check but load a file from the working directory. diff --git a/packages/ui/src/actions/get-email-path-from-slug.ts b/packages/ui/src/actions/get-email-path-from-slug.ts index 4d6933d7bd..59b8f619c0 100644 --- a/packages/ui/src/actions/get-email-path-from-slug.ts +++ b/packages/ui/src/actions/get-email-path-from-slug.ts @@ -4,10 +4,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { cache } from 'react'; import { emailsDirectoryAbsolutePath } from '../app/env'; -import { isPathWithinEmailsDirectory } from '../utils/is-path-within-emails-directory'; +import { resolveWithinEmailsDirectory } from '../utils/resolve-within-emails-directory'; const safeReturn = (candidate: string): string | undefined => { - return isPathWithinEmailsDirectory(candidate) ? candidate : undefined; + return resolveWithinEmailsDirectory(candidate) ? candidate : undefined; }; export const getEmailPathFromSlug = cache(async (slug: string) => { diff --git a/packages/ui/src/actions/render-email-by-path.spec.ts b/packages/ui/src/actions/render-email-by-path.spec.ts index 4ed8790918..642479eee7 100644 --- a/packages/ui/src/actions/render-email-by-path.spec.ts +++ b/packages/ui/src/actions/render-email-by-path.spec.ts @@ -62,6 +62,16 @@ describe('renderEmailByPath() with raw .html templates', () => { expect(result.error.message).toMatch(/outside of the configured emails/); }); + it('renders a relative path from the emails directory, not the working directory', async () => { + expect(process.cwd()).not.toBe(emailsRoot); + + const result = await renderEmailByPath('raw-html-email.html', true); + + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.markup).toContain('

Hello from a raw HTML email

'); + }); + it('refuses to escape via traversal segments', async () => { const result = await renderEmailByPath( path.join(emailsRoot, '..', '..', '..', 'etc', 'passwd'), @@ -178,6 +188,18 @@ describe('renderEmailByPath() with preview props overrides', () => { expect(result.markup).toContain('alanturing'); }); + it('bundles a relative template path from the emails directory', { + timeout: 15_000, + }, async () => { + expect(process.cwd()).not.toBe(emailsRoot); + + const result = await render('vercel-invite-user.tsx', true); + + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.markup).toContain('alanturing'); + }); + it('renders with overridden props without corrupting the default cache', { timeout: 15_000, }, async () => { diff --git a/packages/ui/src/actions/render-email-by-path.tsx b/packages/ui/src/actions/render-email-by-path.tsx index 9ea27b6702..78355bc4db 100644 --- a/packages/ui/src/actions/render-email-by-path.tsx +++ b/packages/ui/src/actions/render-email-by-path.tsx @@ -15,8 +15,8 @@ import { convertStackWithSourceMap } from '../utils/convert-stack-with-sourcemap import { createJsxRuntime } from '../utils/create-jsx-runtime'; import { getUserEsbuildPlugins } from '../utils/esbuild/load-esbuild-plugins'; import { getEmailComponent } from '../utils/get-email-component'; -import { isPathWithinEmailsDirectory } from '../utils/is-path-within-emails-directory'; import { registerSpinnerAutostopping } from '../utils/register-spinner-autostopping'; +import { resolveWithinEmailsDirectory } from '../utils/resolve-within-emails-directory'; import { createSpinner, type Spinner, @@ -73,8 +73,11 @@ const invalidateCacheFor = (emailPath: string) => { // variants) so the next render re-reads the file. Hot reload uses this to // invalidate templates affected by a shared-component save without re-rendering // them eagerly. -export const invalidateEmailRenderingCache = async (emailPath: string) => { - if (!isPathWithinEmailsDirectory(emailPath)) return; +export const invalidateEmailRenderingCache = async ( + requestedEmailPath: string, +) => { + const emailPath = resolveWithinEmailsDirectory(requestedEmailPath); + if (!emailPath) return; invalidateCacheFor(emailPath); }; @@ -141,15 +144,16 @@ const warnBufferer = createLogBufferer( ); export const renderEmailByPath = async ( - emailPath: string, + requestedEmailPath: string, invalidatingCache = false, previewPropsOverride?: Record, ): Promise => { - if (!isPathWithinEmailsDirectory(emailPath)) { + const emailPath = resolveWithinEmailsDirectory(requestedEmailPath); + if (!emailPath) { return { error: { name: 'Error', - message: `Refusing to render ${path.basename(emailPath)} because it resolves outside of the configured emails directory.`, + message: `Refusing to render ${path.basename(requestedEmailPath)} because it resolves outside of the configured emails directory.`, stack: undefined, }, }; diff --git a/packages/ui/src/utils/is-path-within-emails-directory.ts b/packages/ui/src/utils/is-path-within-emails-directory.ts deleted file mode 100644 index 4b8ca0916c..0000000000 --- a/packages/ui/src/utils/is-path-within-emails-directory.ts +++ /dev/null @@ -1,31 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -const safeRealpath = (target: string): string => { - try { - return fs.realpathSync(target); - } catch { - return path.resolve(target); - } -}; - -/** - * Returns true when `emailPath` resolves to a location inside the emails - * directory configured via REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH. - * - * Server actions accept arbitrary strings from the client, so any path that - * eventually reaches the filesystem must be checked against this boundary to - * prevent traversal (`../../etc/passwd`) and absolute-path escapes. - */ -export const isPathWithinEmailsDirectory = (emailPath: string): boolean => { - const root = process.env.REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH; - if (!root) return false; - - const resolvedRoot = safeRealpath(path.resolve(root)); - const resolvedTarget = safeRealpath(path.resolve(resolvedRoot, emailPath)); - - return ( - resolvedTarget === resolvedRoot || - resolvedTarget.startsWith(resolvedRoot + path.sep) - ); -}; diff --git a/packages/ui/src/utils/is-path-within-emails-directory.spec.ts b/packages/ui/src/utils/resolve-within-emails-directory.spec.ts similarity index 60% rename from packages/ui/src/utils/is-path-within-emails-directory.spec.ts rename to packages/ui/src/utils/resolve-within-emails-directory.spec.ts index 0ecf1b6b6e..4e0a2aa0fe 100644 --- a/packages/ui/src/utils/is-path-within-emails-directory.spec.ts +++ b/packages/ui/src/utils/resolve-within-emails-directory.spec.ts @@ -2,9 +2,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { isPathWithinEmailsDirectory } from './is-path-within-emails-directory'; +import { resolveWithinEmailsDirectory } from './resolve-within-emails-directory'; -describe('isPathWithinEmailsDirectory()', () => { +describe('resolveWithinEmailsDirectory()', () => { const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rxe-path-guard-')); const emailsRoot = path.join(tmpRoot, 'emails'); const outsideFile = path.join(tmpRoot, 'secret.txt'); @@ -31,41 +31,46 @@ describe('isPathWithinEmailsDirectory()', () => { fs.rmSync(tmpRoot, { recursive: true, force: true }); }); - it('accepts a file directly inside the emails directory', () => { + const realWelcomePath = () => + fs.realpathSync(path.join(emailsRoot, 'welcome.tsx')); + + it('resolves a file directly inside the emails directory', () => { expect( - isPathWithinEmailsDirectory(path.join(emailsRoot, 'welcome.tsx')), - ).toBe(true); + resolveWithinEmailsDirectory(path.join(emailsRoot, 'welcome.tsx')), + ).toBe(realWelcomePath()); }); - it('accepts the emails directory itself', () => { - expect(isPathWithinEmailsDirectory(emailsRoot)).toBe(true); + it('resolves the emails directory itself', () => { + expect(resolveWithinEmailsDirectory(emailsRoot)).toBe( + fs.realpathSync(emailsRoot), + ); }); - it('accepts a relative path that resolves inside the emails directory', () => { - expect(isPathWithinEmailsDirectory('welcome.tsx')).toBe(true); - expect(isPathWithinEmailsDirectory('./welcome.tsx')).toBe(true); + it('resolves a relative path against the emails directory', () => { + expect(resolveWithinEmailsDirectory('welcome.tsx')).toBe(realWelcomePath()); + expect(resolveWithinEmailsDirectory('./welcome.tsx')).toBe( + realWelcomePath(), + ); }); it('rejects absolute paths outside the emails directory', () => { - expect(isPathWithinEmailsDirectory(outsideFile)).toBe(false); - expect(isPathWithinEmailsDirectory('/etc/passwd')).toBe(false); + expect(resolveWithinEmailsDirectory(outsideFile)).toBeUndefined(); + expect(resolveWithinEmailsDirectory('/etc/passwd')).toBeUndefined(); }); it('rejects traversal attempts via ../', () => { - expect(isPathWithinEmailsDirectory('../secret.txt')).toBe(false); - expect(isPathWithinEmailsDirectory('../../etc/passwd')).toBe(false); + expect(resolveWithinEmailsDirectory('../secret.txt')).toBeUndefined(); + expect(resolveWithinEmailsDirectory('../../etc/passwd')).toBeUndefined(); expect( - isPathWithinEmailsDirectory(path.join(emailsRoot, '..', 'secret.txt')), - ).toBe(false); + resolveWithinEmailsDirectory(path.join(emailsRoot, '..', 'secret.txt')), + ).toBeUndefined(); }); it('rejects sibling directories with a shared prefix', () => { const sibling = `${emailsRoot}-other`; fs.mkdirSync(sibling, { recursive: true }); try { - expect(isPathWithinEmailsDirectory(path.join(sibling, 'evil.tsx'))).toBe( - false, - ); + expect(resolveWithinEmailsDirectory(path.join(sibling, 'evil.tsx'))).toBeUndefined(); } finally { fs.rmSync(sibling, { recursive: true, force: true }); } @@ -80,7 +85,7 @@ describe('isPathWithinEmailsDirectory()', () => { return; } try { - expect(isPathWithinEmailsDirectory(symlinkPath)).toBe(false); + expect(resolveWithinEmailsDirectory(symlinkPath)).toBeUndefined(); } finally { fs.rmSync(symlinkPath, { force: true }); } @@ -91,8 +96,8 @@ describe('isPathWithinEmailsDirectory()', () => { delete process.env.REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH; try { expect( - isPathWithinEmailsDirectory(path.join(emailsRoot, 'welcome.tsx')), - ).toBe(false); + resolveWithinEmailsDirectory(path.join(emailsRoot, 'welcome.tsx')), + ).toBeUndefined(); } finally { process.env.REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH = saved; } diff --git a/packages/ui/src/utils/resolve-within-emails-directory.ts b/packages/ui/src/utils/resolve-within-emails-directory.ts new file mode 100644 index 0000000000..05603dbee8 --- /dev/null +++ b/packages/ui/src/utils/resolve-within-emails-directory.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const safeRealpath = (target: string): string => { + try { + return fs.realpathSync(target); + } catch { + return path.resolve(target); + } +}; + +/** + * Resolves `emailPath` against the emails directory configured via + * REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH and returns its real path, or + * `undefined` when it lands outside of it. + * + * Server actions accept arbitrary strings from the client, so any path that + * eventually reaches the filesystem must go through this boundary to prevent + * traversal (`../../etc/passwd`) and absolute-path escapes. Callers must use the + * returned path: resolving the original one again (e.g. against the working + * directory) would read a different file than the one that was checked. + */ +export const resolveWithinEmailsDirectory = ( + emailPath: string, +): string | undefined => { + const root = process.env.REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH; + if (!root) return undefined; + + const resolvedRoot = safeRealpath(path.resolve(root)); + const resolvedTarget = safeRealpath(path.resolve(resolvedRoot, emailPath)); + + if ( + resolvedTarget !== resolvedRoot && + !resolvedTarget.startsWith(resolvedRoot + path.sep) + ) { + return undefined; + } + + return resolvedTarget; +}; From 34f86fcfdd0b408180431e6979cdafca84eccb5d Mon Sep 17 00:00:00 2001 From: dielduarte Date: Thu, 29 Oct 2026 10:27:06 -0400 Subject: [PATCH 2/2] fix(ui): use a single startsWith guard so the path check is recognized --- .../src/utils/resolve-within-emails-directory.spec.ts | 10 +++++----- .../ui/src/utils/resolve-within-emails-directory.ts | 7 +------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/utils/resolve-within-emails-directory.spec.ts b/packages/ui/src/utils/resolve-within-emails-directory.spec.ts index 4e0a2aa0fe..0dbbfaeead 100644 --- a/packages/ui/src/utils/resolve-within-emails-directory.spec.ts +++ b/packages/ui/src/utils/resolve-within-emails-directory.spec.ts @@ -40,10 +40,8 @@ describe('resolveWithinEmailsDirectory()', () => { ).toBe(realWelcomePath()); }); - it('resolves the emails directory itself', () => { - expect(resolveWithinEmailsDirectory(emailsRoot)).toBe( - fs.realpathSync(emailsRoot), - ); + it('rejects the emails directory itself', () => { + expect(resolveWithinEmailsDirectory(emailsRoot)).toBeUndefined(); }); it('resolves a relative path against the emails directory', () => { @@ -70,7 +68,9 @@ describe('resolveWithinEmailsDirectory()', () => { const sibling = `${emailsRoot}-other`; fs.mkdirSync(sibling, { recursive: true }); try { - expect(resolveWithinEmailsDirectory(path.join(sibling, 'evil.tsx'))).toBeUndefined(); + expect( + resolveWithinEmailsDirectory(path.join(sibling, 'evil.tsx')), + ).toBeUndefined(); } finally { fs.rmSync(sibling, { recursive: true, force: true }); } diff --git a/packages/ui/src/utils/resolve-within-emails-directory.ts b/packages/ui/src/utils/resolve-within-emails-directory.ts index 05603dbee8..2ee553aacb 100644 --- a/packages/ui/src/utils/resolve-within-emails-directory.ts +++ b/packages/ui/src/utils/resolve-within-emails-directory.ts @@ -29,12 +29,7 @@ export const resolveWithinEmailsDirectory = ( const resolvedRoot = safeRealpath(path.resolve(root)); const resolvedTarget = safeRealpath(path.resolve(resolvedRoot, emailPath)); - if ( - resolvedTarget !== resolvedRoot && - !resolvedTarget.startsWith(resolvedRoot + path.sep) - ) { - return undefined; - } + if (!resolvedTarget.startsWith(resolvedRoot + path.sep)) return undefined; return resolvedTarget; };