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
20 changes: 12 additions & 8 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
PRODUCTION,
} from "@/utils/config";
import { initConfig } from "./utils/init";
import { resolveStaticPath } from "@/utils/staticFile";

await initConfig();

Expand Down Expand Up @@ -62,7 +63,12 @@ function getMimeType(filePath: string): string {
return mimeTypes[ext || ""] || "application/octet-stream";
}

async function serveStaticFile(filePath: string) {
async function serveStaticFile(rootDir: string, requestPath: string) {
const filePath = resolveStaticPath(rootDir, requestPath);
if (!filePath) {
return null;
}

try {
const fileStat = await stat(filePath);
if (fileStat.isFile()) {
Expand Down Expand Up @@ -91,7 +97,7 @@ async function docsPlugin(dir: string) {
// Handle TanStack Start's __tsr static server function cache requests
// These are requested from root path, not /docs/
.get("/__tsr/*", async ({ path, status }) => {
const response = await serveStaticFile(join(dir, path));
const response = await serveStaticFile(dir, path);
return response || status(404);
})
.get("/docs", ({ status }) => {
Expand All @@ -107,17 +113,15 @@ async function docsPlugin(dir: string) {
return status(404);
}
const subPath = path.replace("/docs", "");
const exactPath = join(dir, subPath);

// Try to serve as static file
const staticResponse = await serveStaticFile(exactPath);
const staticResponse = await serveStaticFile(dir, subPath);
if (staticResponse) {
return staticResponse;
}

// Check if there's a specific HTML file for this path (directory with index.html)
const specificHtmlPath = join(dir, subPath, "index.html");
if (await exists(specificHtmlPath)) {
const specificHtmlPath = resolveStaticPath(dir, `${subPath}/index.html`);
if (specificHtmlPath && (await exists(specificHtmlPath))) {
const html = await readFile(specificHtmlPath, "utf-8");
return new Response(html, {
headers: { "Content-Type": "text/html" },
Expand Down Expand Up @@ -166,7 +170,7 @@ async function spaPlugin(dir: string) {
}

// Try to serve as static file first
const staticResponse = await serveStaticFile(join(dir, path));
const staticResponse = await serveStaticFile(dir, path);
if (staticResponse) {
return staticResponse;
}
Expand Down
28 changes: 28 additions & 0 deletions backend/src/utils/staticFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { resolveStaticPath } from "./staticFile";

describe("resolveStaticPath", () => {
test("resolves request paths inside the static root", () => {
expect(resolveStaticPath("/app/public", "/assets/main.js")).toBe(
"/app/public/assets/main.js",
);
});

test("allows the static root itself", () => {
expect(resolveStaticPath("/app/public", "/")).toBe("/app/public");
});

test("blocks parent-directory traversal", () => {
expect(resolveStaticPath("/app/public", "/../../etc/passwd")).toBeNull();
});

test("blocks traversal without a leading slash", () => {
expect(resolveStaticPath("/app/public", "../../etc/passwd")).toBeNull();
});

test("normalizes dot segments that stay inside the root", () => {
expect(resolveStaticPath("/app/public", "/assets/../index.html")).toBe(
"/app/public/index.html",
);
});
});
17 changes: 17 additions & 0 deletions backend/src/utils/staticFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { isAbsolute, relative, resolve } from "node:path";

export function resolveStaticPath(
rootDir: string,
requestPath: string,
): string | null {
const resolvedRoot = resolve(rootDir);
const normalizedRequestPath = requestPath.replace(/^\/+/u, "");
const resolvedPath = resolve(resolvedRoot, normalizedRequestPath);
const relativePath = relative(resolvedRoot, resolvedPath);

if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
return null;
}

return resolvedPath;
}
Comment on lines +3 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For improved robustness against malformed input, consider wrapping the path resolution logic in a try...catch block. Functions from node:path like resolve can throw a TypeError if the path contains a null byte (\0), which is a common technique for probing for vulnerabilities. An unhandled exception would cause a 500 error. By catching this and returning null, you can treat it as just another invalid path and return a 404, which is a more graceful and secure way to handle it.

Suggested change
export function resolveStaticPath(
rootDir: string,
requestPath: string,
): string | null {
const resolvedRoot = resolve(rootDir);
const normalizedRequestPath = requestPath.replace(/^\/+/u, "");
const resolvedPath = resolve(resolvedRoot, normalizedRequestPath);
const relativePath = relative(resolvedRoot, resolvedPath);
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
return null;
}
return resolvedPath;
}
export function resolveStaticPath(
rootDir: string,
requestPath: string,
): string | null {
try {
const resolvedRoot = resolve(rootDir);
const normalizedRequestPath = requestPath.replace(/^\/+/u, "");
const resolvedPath = resolve(resolvedRoot, normalizedRequestPath);
const relativePath = relative(resolvedRoot, resolvedPath);
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
return null;
}
return resolvedPath;
} catch {
return null;
}
}
References
  1. This suggestion aligns with the principle of handling potentially malformed external input gracefully. Similar to how JSON.parse should be wrapped in a try...catch block to handle malformed JSON and prevent crashes, as per the rule: 'When parsing JSON from a data source, even a trusted one, wrap JSON.parse in a try...catch block to handle potential malformed data gracefully and prevent crashes.' Applying try...catch to node:path.resolve for user-provided paths ensures similar robustness against invalid or malicious input like null bytes.

Comment on lines +3 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check Elysia's path handling behavior and whether it URL-decodes paths

# Search for Elysia documentation or tests about path parameter behavior
rg -n -C3 'path.*decode' --type=ts

# Check if there's any URL decoding in the codebase for request paths
rg -n 'decodeURI' --type=ts

Repository: EM-GeekLab/NexusGate

Length of output: 355


🌐 Web query:

Does Elysia framework URL decode the path parameter before passing to route handlers?

💡 Result:

No, the Elysia framework does not automatically URL decode path parameters before passing them to route handlers. Path parameters are extracted from the raw pathname segments as received by the server (after browser/server encoding), without additional decoding by Elysia. Developers must manually decode if needed, e.g., using decodeURI in a transform hook.

Citations:


requestPath 进行 URL 解码以防止编码形式的路径遍历攻击。

该函数未对 requestPath 执行 URL 解码。根据 Elysia 框架的行为,路径参数在传入路由处理程序前不会自动进行 URL 解码,需要开发者手动处理。这意味着攻击者可使用百分号编码的遍历序列(如 %2e%2e%2f 代表 ../)绕过当前防护:

  • path.resolve() 不会将 %2e 解释为 .,导致编码形式的路径遍历序列被当作字面路径名处理
  • relativePath.startsWith("..") 检查无法捕获编码形式的遍历前缀
  • 函数可能返回根目录外的路径

修复方案: 在路径规范化前添加 URL 解码:

const decodedPath = decodeURIComponent(requestPath);
const normalizedRequestPath = decodedPath.replace(/^\/+/u, "");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/utils/staticFile.ts` around lines 3 - 17, The resolveStaticPath
function does not URL-decode the incoming requestPath, allowing encoded
traversal sequences like %2e%2e%2f to bypass the ../ check; fix by decoding
requestPath (use decodeURIComponent) before computing normalizedRequestPath so
the subsequent resolve/resolvedPath/relativePath checks catch encoded traversal,
and ensure you handle/deal with potential decode errors (e.g., wrap
decodeURIComponent in try/catch and return null on failure) while keeping the
rest of resolveStaticPath logic unchanged.

Loading