Skip to content

Commit ea02028

Browse files
authored
fix(react-email): serve static files with URL-encoded names (#3671)
1 parent a500725 commit ea02028

4 files changed

Lines changed: 98 additions & 5 deletions

File tree

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+
Serve static assets with URL-encoded filenames correctly in the preview server while preventing decoded paths from escaping the static directory.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import fs from 'node:fs';
2+
import type http from 'node:http';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { serveStaticFile } from './serve-static-file.js';
6+
7+
const mkTempDir = () =>
8+
fs.mkdtempSync(path.join(os.tmpdir(), 'serve-static-file-'));
9+
10+
const createResponse = () => {
11+
let body = '';
12+
const response = {
13+
statusCode: 200,
14+
setHeader: vi.fn(),
15+
end: vi.fn((chunk?: string | Buffer) => {
16+
if (chunk) body += chunk.toString();
17+
}),
18+
} as unknown as http.ServerResponse;
19+
20+
return { response, getBody: () => body };
21+
};
22+
23+
test('serves static files with URL-encoded characters in their names', async () => {
24+
const projectDir = mkTempDir();
25+
const staticDir = path.join(projectDir, 'emails', 'static');
26+
const contents = 'asset contents';
27+
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
28+
const { response, getBody } = createResponse();
29+
30+
fs.mkdirSync(staticDir, { recursive: true });
31+
fs.writeFileSync(path.join(staticDir, 'company logo.txt'), contents);
32+
33+
await serveStaticFile(response, '/static/company%20logo.txt', 'emails');
34+
35+
expect(response.statusCode).toBe(200);
36+
expect(getBody()).toBe(contents);
37+
38+
cwdSpy.mockRestore();
39+
fs.rmSync(projectDir, { recursive: true });
40+
});
41+
42+
test('does not serve URL-encoded paths outside the static directory', async () => {
43+
const projectDir = mkTempDir();
44+
const staticDir = path.join(projectDir, 'emails', 'static');
45+
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
46+
const { response } = createResponse();
47+
48+
fs.mkdirSync(staticDir, { recursive: true });
49+
fs.writeFileSync(path.join(projectDir, 'secret.txt'), 'secret contents');
50+
51+
await serveStaticFile(
52+
response,
53+
'/static/%2e%2e%2f%2e%2e%2fsecret.txt',
54+
'emails',
55+
);
56+
57+
expect(response.statusCode).toBe(403);
58+
59+
cwdSpy.mockRestore();
60+
fs.rmSync(projectDir, { recursive: true });
61+
});
62+
63+
test('returns a bad request for malformed URL encoding', async () => {
64+
const { response } = createResponse();
65+
66+
await serveStaticFile(response, '/static/image%E0%A4%A.png', 'emails');
67+
68+
expect(response.statusCode).toBe(400);
69+
});

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,35 @@ export const serveStaticFile = async (
88
pathname: string,
99
staticDirRelativePath: string,
1010
) => {
11-
const pathname_ = pathname.replace('/static', './static');
12-
const ext = path.parse(pathname_).ext;
11+
let decodedPathname: string;
12+
try {
13+
decodedPathname = decodeURIComponent(pathname);
14+
} catch {
15+
res.statusCode = 400;
16+
res.end();
17+
return;
18+
}
19+
20+
const staticBaseDir = path.resolve(
21+
process.cwd(),
22+
staticDirRelativePath,
23+
'static',
24+
);
25+
const staticFilePath = decodedPathname.replace(/^\/static\/?/, '');
26+
const fileAbsolutePath = path.resolve(staticBaseDir, staticFilePath);
27+
const relativeFilePath = path.relative(staticBaseDir, fileAbsolutePath);
1328

14-
const staticBaseDir = path.resolve(process.cwd(), staticDirRelativePath);
15-
const fileAbsolutePath = path.resolve(staticBaseDir, pathname_);
16-
if (!fileAbsolutePath.startsWith(staticBaseDir)) {
29+
if (
30+
relativeFilePath.startsWith(`..${path.sep}`) ||
31+
path.isAbsolute(relativeFilePath)
32+
) {
1733
res.statusCode = 403;
1834
res.end();
1935
return;
2036
}
2137

38+
const ext = path.extname(fileAbsolutePath);
39+
2240
try {
2341
const fileHandle = await fs.open(fileAbsolutePath, 'r');
2442

β€Žpackages/react-email/src/cli/utils/tree.spec.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ test('tree(__dirname, 2)', async () => {
1111
β”‚ β”œβ”€β”€ hot-reloading
1212
β”‚ β”œβ”€β”€ get-env-variables-for-preview-app.ts
1313
β”‚ β”œβ”€β”€ index.ts
14+
β”‚ β”œβ”€β”€ serve-static-file.spec.ts
1415
β”‚ β”œβ”€β”€ serve-static-file.ts
1516
β”‚ └── start-dev-server.ts
1617
β”œβ”€β”€ types

0 commit comments

Comments
Β (0)