Skip to content

Commit 54769fc

Browse files
fix(react-email): prevent export --outDir from deleting the project directory (#3749)
Co-authored-by: Vyacheslav Kurilyak <320287930+vkurilyak@users.noreply.github.com> Co-authored-by: Felipe Freitag <felipe.freitag@resend.com>
1 parent b0b4668 commit 54769fc

3 files changed

Lines changed: 188 additions & 2 deletions

File tree

‎.changeset/export-outdir-guard.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'react-email': patch
3+
---
4+
5+
Prevent `email export` from deleting the project, the home directory, the email templates or the static assets when `--outDir` points at them.

‎packages/react-email/src/cli/commands/export.ts‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs';
2+
import os from 'node:os';
23
import path from 'node:path';
34
import { Worker } from 'node:worker_threads';
45
import type { Options } from '@react-email/render';
@@ -41,6 +42,55 @@ type ExportTemplatesOptions = Options & {
4142
esbuildPlugins?: string;
4243
};
4344

45+
const isInside = (parent: string, child: string) =>
46+
child.startsWith(`${parent}${path.sep}`);
47+
48+
export const getUnsafeOutDirMessage = (
49+
outDir: string,
50+
emailsDirectoryPath: string,
51+
{ cwd, homeDirectory }: { cwd: string; homeDirectory: string },
52+
): string | undefined => {
53+
const resolvedOutDir = path.resolve(cwd, outDir);
54+
const resolvedEmailsDirectory = path.resolve(cwd, emailsDirectoryPath);
55+
const staticDirectory = path.join(resolvedEmailsDirectory, 'static');
56+
57+
if (resolvedOutDir === path.parse(resolvedOutDir).root) {
58+
return "--outDir can't be the filesystem root.";
59+
}
60+
61+
if (resolvedOutDir === homeDirectory) {
62+
return "--outDir can't be your home directory.";
63+
}
64+
65+
if (isInside(resolvedOutDir, homeDirectory)) {
66+
return "--outDir can't contain your home directory.";
67+
}
68+
69+
if (resolvedOutDir === cwd) {
70+
return "--outDir can't be the project directory.";
71+
}
72+
73+
if (isInside(resolvedOutDir, cwd)) {
74+
return "--outDir can't contain the project directory.";
75+
}
76+
77+
if (
78+
resolvedOutDir === resolvedEmailsDirectory ||
79+
isInside(resolvedOutDir, resolvedEmailsDirectory)
80+
) {
81+
return "--outDir can't contain your email templates.";
82+
}
83+
84+
if (
85+
resolvedOutDir === staticDirectory ||
86+
isInside(staticDirectory, resolvedOutDir)
87+
) {
88+
return "--outDir can't be your static assets directory.";
89+
}
90+
91+
return undefined;
92+
};
93+
4494
// Batch so esbuild's Go-side dep graph isn't held for every entry at once.
4595
const BUILD_BATCH_SIZE = 10;
4696

@@ -116,8 +166,36 @@ export const exportTemplates = async (
116166
process.exit(1);
117167
}
118168

119-
if (fs.existsSync(pathToWhereEmailMarkupShouldBeDumped)) {
120-
fs.rmSync(pathToWhereEmailMarkupShouldBeDumped, { recursive: true });
169+
const resolvedPathToWhereEmailMarkupShouldBeDumped = path.resolve(
170+
process.cwd(),
171+
pathToWhereEmailMarkupShouldBeDumped,
172+
);
173+
174+
if (fs.existsSync(resolvedPathToWhereEmailMarkupShouldBeDumped)) {
175+
const unsafeOutDirMessage = getUnsafeOutDirMessage(
176+
resolvedPathToWhereEmailMarkupShouldBeDumped,
177+
emailsDirectoryPath,
178+
{ cwd: process.cwd(), homeDirectory: os.homedir() },
179+
);
180+
181+
if (unsafeOutDirMessage) {
182+
const message = `${unsafeOutDirMessage} Choose a different --outDir, such as "out".`;
183+
184+
if (spinner) {
185+
stopSpinnerAndPersist(spinner, {
186+
symbol: logSymbols.error,
187+
text: message,
188+
});
189+
} else {
190+
console.error(message);
191+
}
192+
193+
process.exit(1);
194+
}
195+
196+
fs.rmSync(resolvedPathToWhereEmailMarkupShouldBeDumped, {
197+
recursive: true,
198+
});
121199
}
122200

123201
const allTemplates = getEmailTemplatesFromDirectory(emailsDirectoryMetadata);
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { exportTemplates, getUnsafeOutDirMessage } from '../export.js';
5+
6+
const homeDirectory = path.resolve('/home/user');
7+
const cwd = path.join(homeDirectory, 'project');
8+
const emailsDirectoryPath = './emails';
9+
10+
const getMessage = (outDir: string, emailsDirectory = emailsDirectoryPath) =>
11+
getUnsafeOutDirMessage(outDir, emailsDirectory, { cwd, homeDirectory });
12+
13+
test('blocks the filesystem root', () => {
14+
expect(getMessage(path.parse(cwd).root)).toBe(
15+
"--outDir can't be the filesystem root.",
16+
);
17+
});
18+
19+
test('blocks the home directory', () => {
20+
expect(getMessage(homeDirectory)).toBe(
21+
"--outDir can't be your home directory.",
22+
);
23+
expect(getMessage('..')).toBe("--outDir can't be your home directory.");
24+
});
25+
26+
test('blocks a parent of the home directory', () => {
27+
expect(getMessage(path.dirname(homeDirectory))).toBe(
28+
"--outDir can't contain your home directory.",
29+
);
30+
});
31+
32+
test('blocks the project directory', () => {
33+
expect(getMessage('.')).toBe("--outDir can't be the project directory.");
34+
expect(getMessage(cwd)).toBe("--outDir can't be the project directory.");
35+
});
36+
37+
test('blocks a parent of the project directory', () => {
38+
const nestedCwd = path.join(cwd, 'apps', 'emails');
39+
expect(
40+
getUnsafeOutDirMessage('..', emailsDirectoryPath, {
41+
cwd: nestedCwd,
42+
homeDirectory,
43+
}),
44+
).toBe("--outDir can't contain the project directory.");
45+
});
46+
47+
test('blocks the email templates directory and its parents', () => {
48+
const message = "--outDir can't contain your email templates.";
49+
expect(getMessage('emails')).toBe(message);
50+
expect(getMessage('./emails/')).toBe(message);
51+
expect(getMessage(path.join(cwd, 'emails'))).toBe(message);
52+
expect(getMessage('src', './src/emails')).toBe(message);
53+
});
54+
55+
test('blocks the static assets directory', () => {
56+
const message = "--outDir can't be your static assets directory.";
57+
expect(getMessage('emails/static')).toBe(message);
58+
expect(getMessage('emails/static/images')).toBe(message);
59+
});
60+
61+
test('allows other directories', () => {
62+
expect(getMessage('out')).toBeUndefined();
63+
expect(getMessage('out/nested')).toBeUndefined();
64+
expect(getMessage('emails/out')).toBeUndefined();
65+
expect(getMessage('../sibling')).toBeUndefined();
66+
expect(getMessage('/tmp/react-email-out')).toBeUndefined();
67+
});
68+
69+
test('refuses to remove the project directory and keeps files intact', async () => {
70+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'export-guard-'));
71+
const emailsDir = path.join(projectDir, 'emails');
72+
fs.mkdirSync(emailsDir, { recursive: true });
73+
fs.writeFileSync(
74+
path.join(emailsDir, 'welcome.tsx'),
75+
'export default () => null;',
76+
);
77+
fs.writeFileSync(path.join(projectDir, 'package.json'), '{}');
78+
79+
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
80+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((
81+
code?: number,
82+
) => {
83+
throw new Error(`process.exit(${code})`);
84+
}) as never);
85+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
86+
87+
try {
88+
await expect(
89+
exportTemplates('.', 'emails', { silent: true }),
90+
).rejects.toThrow('process.exit(1)');
91+
92+
expect(errorSpy).toHaveBeenCalledWith(
93+
`--outDir can't be the project directory. Choose a different --outDir, such as "out".`,
94+
);
95+
expect(fs.existsSync(path.join(projectDir, 'package.json'))).toBe(true);
96+
expect(fs.existsSync(path.join(emailsDir, 'welcome.tsx'))).toBe(true);
97+
} finally {
98+
cwdSpy.mockRestore();
99+
exitSpy.mockRestore();
100+
errorSpy.mockRestore();
101+
fs.rmSync(projectDir, { recursive: true, force: true });
102+
}
103+
});

0 commit comments

Comments
 (0)