Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ui-resolve-email-path-once.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/ui/src/actions/get-email-path-from-slug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/actions/render-email-by-path.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<h1>Hello from a raw HTML email</h1>');
});

it('refuses to escape via traversal segments', async () => {
const result = await renderEmailByPath(
path.join(emailsRoot, '..', '..', '..', 'etc', 'passwd'),
Expand Down Expand Up @@ -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 () => {
Expand Down
16 changes: 10 additions & 6 deletions packages/ui/src/actions/render-email-by-path.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

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>

if (!emailPath) return;
invalidateCacheFor(emailPath);
};

Expand Down Expand Up @@ -141,15 +144,16 @@ const warnBufferer = createLogBufferer(
);

export const renderEmailByPath = async (
emailPath: string,
requestedEmailPath: string,
invalidatingCache = false,
previewPropsOverride?: Record<string, unknown>,
): Promise<EmailRenderingResult> => {
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,
},
};
Expand Down
31 changes: 0 additions & 31 deletions packages/ui/src/utils/is-path-within-emails-directory.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 });
}
Expand All @@ -80,7 +85,7 @@ describe('isPathWithinEmailsDirectory()', () => {
return;
}
try {
expect(isPathWithinEmailsDirectory(symlinkPath)).toBe(false);
expect(resolveWithinEmailsDirectory(symlinkPath)).toBeUndefined();
} finally {
fs.rmSync(symlinkPath, { force: true });
}
Expand All @@ -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;
}
Expand Down
35 changes: 35 additions & 0 deletions packages/ui/src/utils/resolve-within-emails-directory.ts
Original file line number Diff line number Diff line change
@@ -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);

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>

}
};

/**
* 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;
};
Loading