Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/modernjs-ssr-bundle-serving.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@module-federation/modern-js': patch
'@module-federation/modern-js-v3': patch
---

Harden SSR `/bundles` and JSON asset serving against path traversal, and set `Content-Length` from UTF-8 byte length so non-ASCII federated chunks are not truncated.
76 changes: 74 additions & 2 deletions packages/modernjs-v3/src/cli/ssrPlugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@ import path from 'path';
import { createRequire } from 'node:module';
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { describe, expect, it, rs } from '@rstest/core';
import { beforeEach, describe, expect, it, rs } from '@rstest/core';

const fsMocks = rs.hoisted(() => ({
statSync: rs.fn(),
createReadStream: rs.fn(() => ({ pipe: rs.fn() })),
}));

rs.mock('fs', () => ({
default: fsMocks,
...fsMocks,
}));

beforeEach(() => {
rs.clearAllMocks();
});

const nodeRequire = createRequire(__filename);

Expand All @@ -29,12 +43,16 @@ const createPluginHarness = async () => {
} as any;

let rsbuildPlugin: any;
let devServerMiddleware: any;
const api = {
_internalRuntimePlugins: rs.fn(),
_internalServerPlugins: rs.fn(),
config: rs.fn((callback) => {
const config = callback();
rsbuildPlugin = config.builderPlugins[0];
const middlewares: any[] = [];
config.dev.setupMiddlewares(middlewares);
devServerMiddleware = middlewares[0];
}),
getAppContext: rs.fn(() => ({ bundlerType: 'rspack' })),
getConfig: rs.fn(() => ({ server: { ssr: true } })),
Expand Down Expand Up @@ -74,7 +92,7 @@ const createPluginHarness = async () => {
{ name: 'node' },
);

return { rsbuildApi };
return { rsbuildApi, devServerMiddleware };
};

const runCompiler = (config: import('@rspack/core').Configuration) =>
Expand Down Expand Up @@ -188,4 +206,58 @@ describe('moduleFederationSSRPlugin', () => {
await rm(outputDir, { force: true, recursive: true });
}
});

it('serves JSON assets with query and hash suffixes, including ..-prefixed names', async () => {
const { devServerMiddleware } = await createPluginHarness();
const response = { setHeader: rs.fn() };
const stream = { pipe: rs.fn() };
(fsMocks.createReadStream as any).mockReturnValue(stream);

for (const requestPath of [
'/..manifest.json?query=value#hash',
'/nested/..generated/app.json',
]) {
const next = rs.fn();

await devServerMiddleware({ url: requestPath }, response, next);

expect(next).not.toHaveBeenCalled();
}

expect(fsMocks.statSync).toHaveBeenNthCalledWith(
1,
path.resolve(process.cwd(), 'dist', '..manifest.json'),
);
expect(fsMocks.statSync).toHaveBeenNthCalledWith(
2,
path.resolve(process.cwd(), 'dist', 'nested/..generated/app.json'),
);
expect(stream.pipe).toHaveBeenCalledTimes(2);
expect(response.setHeader).toHaveBeenCalledWith(
'Access-Control-Allow-Origin',
'*',
);
});

it('passes through non-JSON and traversal requests', async () => {
const { devServerMiddleware } = await createPluginHarness();

for (const requestPath of [
'/manifest.js?query=value#hash',
'/../outside.json?query=value#hash',
]) {
const next = rs.fn();

await devServerMiddleware(
{ url: requestPath },
{ setHeader: rs.fn() },
next,
);

expect(next).toHaveBeenCalledOnce();
}

expect(fsMocks.statSync).not.toHaveBeenCalled();
expect(fsMocks.createReadStream).not.toHaveBeenCalled();
});
});
18 changes: 15 additions & 3 deletions packages/modernjs-v3/src/cli/ssrPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,11 +340,23 @@ export const moduleFederationSSRPlugin = (
return;
}
try {
const requestPath = (req.url ?? '').split(/[?#]/)[0];
if (
req.url?.includes('.json') &&
!req.url?.includes('hot-update')
path.extname(requestPath) === '.json' &&
!requestPath.includes('hot-update')
) {
const filepath = path.join(process.cwd(), `dist${req.url}`);
const distRoot = path.resolve(process.cwd(), 'dist');
const relativePath = requestPath.replace(/^\/+/, '');
const filepath = path.resolve(distRoot, relativePath);
const relativeToDist = path.relative(distRoot, filepath);
if (
relativeToDist === '..' ||
relativeToDist.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeToDist)
) {
next();
return;
}
fs.statSync(filepath);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader(
Expand Down
27 changes: 26 additions & 1 deletion packages/modernjs-v3/src/server/fileCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { FileCache } from './fileCache';

describe('modern serve static file cache', async () => {
beforeEach(() => {
rs.mocked(readFile).mockClear();
rs.mocked(readFile).mockReset();
rs.mocked(readFile).mockResolvedValue('test');
});

it('should cache file', async () => {
Expand All @@ -36,4 +37,28 @@ describe('modern serve static file cache', async () => {

expect(readFile).toHaveBeenCalledTimes(3);
});

it('accounts for UTF-8 bytes when evicting files', async () => {
rs.mocked(readFile).mockImplementation((filepath) =>
Promise.resolve(filepath === 'unicode.txt' ? '你' : 'a'),
);
const cache = new FileCache(3);

await cache.getFile('unicode.txt');
await cache.getFile('ascii.txt');
await cache.getFile('unicode.txt');

expect(readFile).toHaveBeenCalledTimes(3);
});

it('caches empty files without rejecting their cache entry', async () => {
rs.mocked(readFile).mockResolvedValue('');
const cache = new FileCache(1);

const result = await cache.getFile('empty.txt');
await cache.getFile('empty.txt');

expect(result?.content).toBe('');
expect(readFile).toHaveBeenCalledTimes(1);
});
});
23 changes: 5 additions & 18 deletions packages/modernjs-v3/src/server/fileCache.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import { access, lstat, readFile } from 'fs/promises';
import { lstat, readFile } from 'fs/promises';
import { SizeLimitedCache } from '@module-federation/bridge-react/size-limited-cache';

const pathExists = async (filepath: string): Promise<boolean> => {
try {
await access(filepath);
return true;
} catch {
return false;
}
};

export interface FileResult {
content: string;
lastModified: number;
Expand All @@ -28,12 +19,8 @@ export class FileCache {
* @returns FileResult or null if file doesn't exist
*/
async getFile(filepath: string): Promise<FileResult | null> {
// Check if file exists
if (!(await pathExists(filepath))) {
return null;
}

try {
// lstat alone is enough: ENOENT / access errors return null below.
const stat = await lstat(filepath);
const currentModified = stat.mtimeMs;

Expand All @@ -53,9 +40,9 @@ export class FileCache {
lastModified: currentModified,
};

this.cache.set(filepath, newEntry, {
size: stat.size || content.length,
});
// Charge UTF-8 bytes (never 0 — SizeLimitedCache rejects non-positive sizes).
const size = Math.max(Buffer.byteLength(content, 'utf8'), 1);
this.cache.set(filepath, newEntry, { size });

return {
content,
Expand Down
Loading
Loading