mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #33 from saismrutiranjan18/main
Security: fix information disclosure via dashboard server (issue #20)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { join, isAbsolute, relative, basename } from "node:path";
|
||||
import type { KnowledgeGraph, AnalysisMeta, ProjectConfig } from "../types.js";
|
||||
import type { FingerprintStore } from "../fingerprint.js";
|
||||
import { validateGraph } from "../schema.js";
|
||||
@@ -18,9 +18,68 @@ function ensureDir(projectRoot: string): string {
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise every node's filePath before writing to disk.
|
||||
*
|
||||
* The analysis agent produces absolute paths like:
|
||||
* /Users/alice/company/src/auth.ts
|
||||
*
|
||||
* We convert them to paths relative to projectRoot:
|
||||
* src/auth.ts
|
||||
*
|
||||
* Three cases are handled:
|
||||
* 1. Path is inside projectRoot → make it relative
|
||||
* 2. Path is absolute but outside → keep only the filename (last segment)
|
||||
* 3. Path is already relative → leave it untouched
|
||||
*
|
||||
* This means the developer's home directory, username, and company
|
||||
* directory layout are never written to knowledge-graph.json.
|
||||
*/
|
||||
function sanitiseFilePaths(
|
||||
graph: KnowledgeGraph,
|
||||
projectRoot: string,
|
||||
): KnowledgeGraph {
|
||||
const normalRoot = projectRoot.endsWith("/")
|
||||
? projectRoot
|
||||
: projectRoot + "/";
|
||||
|
||||
const sanitisedNodes = graph.nodes.map((node) => {
|
||||
if (typeof node.filePath !== "string") return node;
|
||||
|
||||
const fp = node.filePath;
|
||||
|
||||
if (!isAbsolute(fp)) {
|
||||
// Already relative — nothing to do.
|
||||
return node;
|
||||
}
|
||||
|
||||
if (fp.startsWith(normalRoot) || fp.startsWith(projectRoot)) {
|
||||
// Inside the project root — make it relative.
|
||||
return { ...node, filePath: relative(projectRoot, fp) };
|
||||
}
|
||||
|
||||
// Absolute but outside the project root — use only the filename
|
||||
// so we leak as little as possible.
|
||||
return { ...node, filePath: basename(fp) };
|
||||
});
|
||||
|
||||
return { ...graph, nodes: sanitisedNodes };
|
||||
}
|
||||
|
||||
export function saveGraph(projectRoot: string, graph: KnowledgeGraph): void {
|
||||
const dir = ensureDir(projectRoot);
|
||||
writeFileSync(join(dir, GRAPH_FILE), JSON.stringify(graph, null, 2), "utf-8");
|
||||
|
||||
// FIX — sanitise absolute file paths before persisting.
|
||||
// Without this, absolute paths like /Users/alice/company/src/auth.ts
|
||||
// are written verbatim into knowledge-graph.json and later served
|
||||
// by the dashboard server, leaking the developer's directory layout.
|
||||
const sanitised = sanitiseFilePaths(graph, projectRoot);
|
||||
|
||||
writeFileSync(
|
||||
join(dir, GRAPH_FILE),
|
||||
JSON.stringify(sanitised, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
export function loadGraph(
|
||||
|
||||
@@ -19,6 +19,13 @@ import { ThemeProvider } from "./themes/index.ts";
|
||||
import { ThemePicker } from "./components/ThemePicker.tsx";
|
||||
import type { ThemeConfig } from "./themes/index.ts";
|
||||
|
||||
// Extract the access token from the URL so protected endpoints can be fetched.
|
||||
const ACCESS_TOKEN = new URLSearchParams(window.location.search).get("token");
|
||||
|
||||
function tokenUrl(path: string): string {
|
||||
return ACCESS_TOKEN ? `${path}?token=${ACCESS_TOKEN}` : path;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const setGraph = useDashboardStore((s) => s.setGraph);
|
||||
@@ -34,7 +41,7 @@ function App() {
|
||||
const [metaTheme, setMetaTheme] = useState<ThemeConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/meta.json")
|
||||
fetch(tokenUrl("/meta.json"))
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((meta) => {
|
||||
if (meta?.theme) setMetaTheme(meta.theme);
|
||||
@@ -133,7 +140,7 @@ function App() {
|
||||
useKeyboardShortcuts(shortcuts);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/knowledge-graph.json")
|
||||
fetch(tokenUrl("/knowledge-graph.json"))
|
||||
.then((res) => res.json())
|
||||
.then((data: unknown) => {
|
||||
const result = validateGraph(data);
|
||||
@@ -162,7 +169,7 @@ function App() {
|
||||
}, [setGraph]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/diff-overlay.json")
|
||||
fetch(tokenUrl("/diff-overlay.json"))
|
||||
.then((res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
|
||||
@@ -3,8 +3,21 @@ import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Generate a one-time token when the server process starts.
|
||||
// This token is printed to the terminal and must be in the URL
|
||||
// to fetch knowledge-graph.json or diff-overlay.json.
|
||||
const ACCESS_TOKEN = crypto.randomBytes(16).toString("hex");
|
||||
|
||||
export default defineConfig({
|
||||
// FIX 1 — bind only to localhost, not 0.0.0.0
|
||||
// This blocks access from any other device on the same LAN / WiFi.
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@understand-anything/core/schema": path.resolve(__dirname, "../core/dist/schema.js"),
|
||||
@@ -12,53 +25,119 @@ export default defineConfig({
|
||||
"@understand-anything/core/types": path.resolve(__dirname, "../core/dist/types.js"),
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: "serve-knowledge-graph",
|
||||
configureServer(server) {
|
||||
// Print the access URL once so the developer can open it.
|
||||
server.httpServer?.once("listening", () => {
|
||||
console.log(
|
||||
`\n 🔑 Dashboard URL: http://127.0.0.1:5173?token=${ACCESS_TOKEN}\n`
|
||||
);
|
||||
});
|
||||
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url === "/knowledge-graph.json") {
|
||||
// GRAPH_DIR env var points to the project being analyzed
|
||||
// Falls back to monorepo root, then public/ (demo)
|
||||
const graphDir = process.env.GRAPH_DIR;
|
||||
const candidates = [
|
||||
...(graphDir
|
||||
? [path.resolve(graphDir, ".understand-anything/knowledge-graph.json")]
|
||||
: []),
|
||||
path.resolve(process.cwd(), ".understand-anything/knowledge-graph.json"),
|
||||
path.resolve(process.cwd(), "../../../.understand-anything/knowledge-graph.json"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
fs.createReadStream(candidate).pipe(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (req.url === "/diff-overlay.json") {
|
||||
const graphDir = process.env.GRAPH_DIR;
|
||||
const candidates = [
|
||||
...(graphDir
|
||||
? [path.resolve(graphDir, ".understand-anything/diff-overlay.json")]
|
||||
: []),
|
||||
path.resolve(process.cwd(), ".understand-anything/diff-overlay.json"),
|
||||
path.resolve(process.cwd(), "../../../.understand-anything/diff-overlay.json"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
fs.createReadStream(candidate).pipe(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1:5173");
|
||||
const pathname = url.pathname;
|
||||
const isProtectedEndpoint =
|
||||
pathname === "/knowledge-graph.json" ||
|
||||
pathname === "/diff-overlay.json" ||
|
||||
pathname === "/meta.json";
|
||||
|
||||
if (!isProtectedEndpoint) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
|
||||
// FIX 3 — require the one-time token on all data endpoints.
|
||||
// Requests without a matching ?token= get a 403.
|
||||
if (url.searchParams.get("token") !== ACCESS_TOKEN) {
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Forbidden: missing or invalid token" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName =
|
||||
pathname === "/diff-overlay.json"
|
||||
? "diff-overlay.json"
|
||||
: pathname === "/meta.json"
|
||||
? "meta.json"
|
||||
: "knowledge-graph.json";
|
||||
|
||||
const graphDir = process.env.GRAPH_DIR;
|
||||
const candidates = [
|
||||
...(graphDir
|
||||
? [path.resolve(graphDir, `.understand-anything/${fileName}`)]
|
||||
: []),
|
||||
path.resolve(process.cwd(), `.understand-anything/${fileName}`),
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
`../../../.understand-anything/${fileName}`
|
||||
),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
|
||||
// FIX 2 — sanitise absolute file paths before sending the JSON.
|
||||
// Nodes can contain filePath values like /Users/alice/company/src/auth.ts.
|
||||
// We convert those to relative paths (src/auth.ts) so the developer's
|
||||
// home directory and company directory layout are not leaked.
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(candidate, "utf-8")) as {
|
||||
nodes?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// Derive the project root from the candidate path so we can
|
||||
// make file paths relative to it.
|
||||
const projectRoot = path.dirname(
|
||||
candidate.replace(
|
||||
`${path.sep}.understand-anything${path.sep}${fileName}`,
|
||||
""
|
||||
)
|
||||
);
|
||||
|
||||
if (Array.isArray(raw.nodes)) {
|
||||
raw.nodes = raw.nodes.map((node) => {
|
||||
if (typeof node.filePath !== "string") return node;
|
||||
const abs = node.filePath;
|
||||
// Only relativise paths that actually sit inside projectRoot.
|
||||
// Leave external or already-relative paths untouched.
|
||||
const rel = abs.startsWith(projectRoot)
|
||||
? abs.slice(projectRoot.length).replace(/^[\\/]/, "")
|
||||
: path.isAbsolute(abs)
|
||||
? path.basename(abs) // absolute but outside root — use filename only
|
||||
: abs; // already relative — keep as-is
|
||||
return { ...node, filePath: rel };
|
||||
});
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(raw));
|
||||
} catch (err) {
|
||||
// If we cannot parse or sanitise the file, refuse to serve it
|
||||
// rather than accidentally leaking raw content.
|
||||
console.error("[understand-anything] Failed to sanitise graph file:", err);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Failed to read graph file" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No matching file found on disk.
|
||||
res.statusCode = 404;
|
||||
if (pathname === "/knowledge-graph.json") {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "No knowledge graph found. Run /understand first." }));
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ Start the Understand Anything dashboard to visualize the knowledge graph for the
|
||||
|
||||
5. Start the Vite dev server pointing at the project's knowledge graph:
|
||||
```bash
|
||||
cd <dashboard-dir> && GRAPH_DIR=<project-dir> npx vite --open
|
||||
cd <dashboard-dir> && GRAPH_DIR=<project-dir> npx vite --host 127.0.0.1 --open
|
||||
```
|
||||
Run this in the background so the user can continue working.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user