@mandujs/core 0.9.26 → 0.9.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bundler/build.ts +12 -5
- package/src/runtime/server.ts +38 -6
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -1118,12 +1118,23 @@ export async function buildClientBundles(
|
|
|
1118
1118
|
// 1. Hydration이 필요한 라우트 필터링
|
|
1119
1119
|
const hydratedRoutes = getHydratedRoutes(manifest);
|
|
1120
1120
|
|
|
1121
|
+
// 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
|
|
1122
|
+
const outDir = options.outDir || path.join(rootDir, ".mandu/client");
|
|
1123
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
1124
|
+
|
|
1125
|
+
// Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
|
|
1126
|
+
// (이전 빌드의 stale 매니페스트 참조 방지)
|
|
1121
1127
|
if (hydratedRoutes.length === 0) {
|
|
1128
|
+
const emptyManifest = createEmptyManifest(env);
|
|
1129
|
+
await fs.writeFile(
|
|
1130
|
+
path.join(rootDir, ".mandu/manifest.json"),
|
|
1131
|
+
JSON.stringify(emptyManifest, null, 2)
|
|
1132
|
+
);
|
|
1122
1133
|
return {
|
|
1123
1134
|
success: true,
|
|
1124
1135
|
outputs: [],
|
|
1125
1136
|
errors: [],
|
|
1126
|
-
manifest:
|
|
1137
|
+
manifest: emptyManifest,
|
|
1127
1138
|
stats: {
|
|
1128
1139
|
totalSize: 0,
|
|
1129
1140
|
totalGzipSize: 0,
|
|
@@ -1134,10 +1145,6 @@ export async function buildClientBundles(
|
|
|
1134
1145
|
};
|
|
1135
1146
|
}
|
|
1136
1147
|
|
|
1137
|
-
// 2. 출력 디렉토리 생성
|
|
1138
|
-
const outDir = options.outDir || path.join(rootDir, ".mandu/client");
|
|
1139
|
-
await fs.mkdir(outDir, { recursive: true });
|
|
1140
|
-
|
|
1141
1148
|
// 3. Runtime 번들 빌드
|
|
1142
1149
|
const runtimeResult = await buildRuntime(outDir, options);
|
|
1143
1150
|
if (!runtimeResult.success) {
|
package/src/runtime/server.ts
CHANGED
|
@@ -204,24 +204,49 @@ function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
|
204
204
|
|
|
205
205
|
// ========== Static File Serving ==========
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* 경로가 허용된 디렉토리 내에 있는지 검증
|
|
209
|
+
* Path traversal 공격 방지
|
|
210
|
+
*/
|
|
211
|
+
function isPathSafe(filePath: string, allowedDir: string): boolean {
|
|
212
|
+
const resolvedPath = path.resolve(filePath);
|
|
213
|
+
const resolvedAllowedDir = path.resolve(allowedDir);
|
|
214
|
+
// 경로가 허용된 디렉토리로 시작하는지 확인 (디렉토리 구분자 포함)
|
|
215
|
+
return resolvedPath.startsWith(resolvedAllowedDir + path.sep) ||
|
|
216
|
+
resolvedPath === resolvedAllowedDir;
|
|
217
|
+
}
|
|
218
|
+
|
|
207
219
|
/**
|
|
208
220
|
* 정적 파일 서빙
|
|
209
221
|
* - /.mandu/client/* : 클라이언트 번들 (Island hydration)
|
|
210
222
|
* - /public/* : 정적 에셋 (이미지, CSS 등)
|
|
211
223
|
* - /favicon.ico : 파비콘
|
|
224
|
+
*
|
|
225
|
+
* 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
|
|
212
226
|
*/
|
|
213
227
|
async function serveStaticFile(pathname: string): Promise<Response | null> {
|
|
214
228
|
let filePath: string | null = null;
|
|
215
229
|
let isBundleFile = false;
|
|
230
|
+
let allowedBaseDir: string;
|
|
231
|
+
|
|
232
|
+
// Path traversal 시도 조기 차단 (정규화 전 raw 체크)
|
|
233
|
+
if (pathname.includes("..")) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
216
236
|
|
|
217
237
|
// 1. 클라이언트 번들 파일 (/.mandu/client/*)
|
|
218
238
|
if (pathname.startsWith("/.mandu/client/")) {
|
|
219
|
-
|
|
239
|
+
// pathname에서 prefix 제거 후 안전하게 조합
|
|
240
|
+
const relativePath = pathname.slice("/.mandu/client/".length);
|
|
241
|
+
allowedBaseDir = path.join(serverSettings.rootDir, ".mandu", "client");
|
|
242
|
+
filePath = path.join(allowedBaseDir, relativePath);
|
|
220
243
|
isBundleFile = true;
|
|
221
244
|
}
|
|
222
|
-
// 2. Public 폴더 파일 (/public/*
|
|
245
|
+
// 2. Public 폴더 파일 (/public/*)
|
|
223
246
|
else if (pathname.startsWith("/public/")) {
|
|
224
|
-
|
|
247
|
+
const relativePath = pathname.slice("/public/".length);
|
|
248
|
+
allowedBaseDir = path.join(serverSettings.rootDir, "public");
|
|
249
|
+
filePath = path.join(allowedBaseDir, relativePath);
|
|
225
250
|
}
|
|
226
251
|
// 3. Public 폴더의 루트 파일 (favicon.ico, robots.txt 등)
|
|
227
252
|
else if (
|
|
@@ -230,11 +255,18 @@ async function serveStaticFile(pathname: string): Promise<Response | null> {
|
|
|
230
255
|
pathname === "/sitemap.xml" ||
|
|
231
256
|
pathname === "/manifest.json"
|
|
232
257
|
) {
|
|
233
|
-
|
|
258
|
+
// 고정된 파일명만 허용 (이미 위에서 정확히 매칭됨)
|
|
259
|
+
const filename = path.basename(pathname);
|
|
260
|
+
allowedBaseDir = path.join(serverSettings.rootDir, serverSettings.publicDir);
|
|
261
|
+
filePath = path.join(allowedBaseDir, filename);
|
|
262
|
+
} else {
|
|
263
|
+
return null; // 정적 파일이 아님
|
|
234
264
|
}
|
|
235
265
|
|
|
236
|
-
|
|
237
|
-
|
|
266
|
+
// 최종 경로 검증: 허용된 디렉토리 내에 있는지 확인
|
|
267
|
+
if (!isPathSafe(filePath, allowedBaseDir!)) {
|
|
268
|
+
console.warn(`[Mandu Security] Path traversal attempt blocked: ${pathname}`);
|
|
269
|
+
return null;
|
|
238
270
|
}
|
|
239
271
|
|
|
240
272
|
try {
|