Skip to content

Commit ec0b2f6

Browse files
committed
fix(react-email): Potential security issues (#2181)
1 parent b015c7d commit ec0b2f6

File tree

3 files changed

+37
-16
lines changed

3 files changed

+37
-16
lines changed

.changeset/flat-llamas-open.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-email": patch
3+
---
4+
5+
Fix access to files outside `static` directory

packages/react-email/src/cli/utils/preview/serve-static-file.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@ export const serveStaticFile = async (
1010
parsedUrl: url.UrlWithParsedQuery,
1111
staticDirRelativePath: string,
1212
) => {
13-
const staticBaseDir = path.join(process.cwd(), staticDirRelativePath);
14-
const pathname = parsedUrl.pathname!;
13+
const pathname = parsedUrl.pathname!.replace('/static', './static');
1514
const ext = path.parse(pathname).ext;
1615

17-
const fileAbsolutePath = path.join(staticBaseDir, pathname);
16+
const staticBaseDir = path.resolve(process.cwd(), staticDirRelativePath);
17+
const fileAbsolutePath = path.resolve(staticBaseDir, pathname);
18+
if (!fileAbsolutePath.startsWith(staticBaseDir)) {
19+
res.statusCode = 403;
20+
res.end();
21+
return;
22+
}
1823

1924
try {
2025
const fileHandle = await fs.open(fileAbsolutePath, 'r');

packages/react-email/src/utils/get-emails-directory-metadata.ts

+24-13
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,33 @@
22
import fs from 'node:fs';
33
import path from 'node:path';
44

5-
const isFileAnEmail = (fullPath: string): boolean => {
6-
const stat = fs.statSync(fullPath);
5+
const isFileAnEmail = async (fullPath: string): Promise<boolean> => {
6+
let fileHandle: fs.promises.FileHandle;
7+
try {
8+
fileHandle = await fs.promises.open(fullPath, 'r');
9+
} catch (exception) {
10+
console.warn(exception);
11+
return false;
12+
}
13+
const stat = await fileHandle.stat();
714

8-
if (stat.isDirectory()) return false;
15+
if (stat.isDirectory()) {
16+
await fileHandle.close();
17+
return false;
18+
}
919

1020
const { ext } = path.parse(fullPath);
1121

12-
if (!['.js', '.tsx', '.jsx'].includes(ext)) return false;
13-
14-
// This is to avoid a possible race condition where the file doesn't exist anymore
15-
// once we are checking if it is an actual email, this could cause issues that
16-
// would be very hard to debug and find out the why of it happening.
17-
if (!fs.existsSync(fullPath)) {
22+
if (!['.js', '.tsx', '.jsx'].includes(ext)) {
23+
await fileHandle.close();
1824
return false;
1925
}
2026

2127
// check with a heuristic to see if the file has at least
2228
// a default export (ES6) or module.exports (CommonJS) or named exports (MDX)
23-
const fileContents = fs.readFileSync(fullPath, 'utf8');
29+
const fileContents = await fileHandle.readFile('utf8');
30+
31+
await fileHandle.close();
2432

2533
// Check for ES6 export default syntax
2634
const hasES6DefaultExport = /\bexport\s+default\b/gm.test(fileContents);
@@ -80,10 +88,13 @@ export const getEmailsDirectoryMetadata = async (
8088
withFileTypes: true,
8189
});
8290

83-
const emailFilenames = dirents
84-
.filter((dirent) =>
91+
const isEmailPredicates = await Promise.all(
92+
dirents.map((dirent) =>
8593
isFileAnEmail(path.join(absolutePathToEmailsDirectory, dirent.name)),
86-
)
94+
),
95+
);
96+
const emailFilenames = dirents
97+
.filter((_, i) => isEmailPredicates[i])
8798
.map((dirent) =>
8899
keepFileExtensions
89100
? dirent.name

0 commit comments

Comments
 (0)