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..0dbbfaeead 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('rejects the emails directory itself', () => {
+ expect(resolveWithinEmailsDirectory(emailsRoot)).toBeUndefined();
});
- 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..2ee553aacb
--- /dev/null
+++ b/packages/ui/src/utils/resolve-within-emails-directory.ts
@@ -0,0 +1,35 @@
+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.startsWith(resolvedRoot + path.sep)) return undefined;
+
+ return resolvedTarget;
+};