diff --git a/apps/ignis-server/server/routes/fs.js b/apps/ignis-server/server/routes/fs.js index 36654b7..e547ab6 100644 --- a/apps/ignis-server/server/routes/fs.js +++ b/apps/ignis-server/server/routes/fs.js @@ -98,7 +98,7 @@ router.get("/stat", async (req, res) => { } catch (e) { res .status(e.code === "ENOENT" ? 404 : 500) - .json({ error: e.message, code: e.code }); + .json({ error: e.code || "internal", code: e.code }); } }); @@ -133,7 +133,7 @@ router.get("/readdir", async (req, res) => { } catch (e) { res .status(e.code === "ENOENT" ? 404 : 500) - .json({ error: e.message, code: e.code }); + .json({ error: e.code || "internal", code: e.code }); } }); @@ -184,7 +184,7 @@ router.get("/readFile", async (req, res) => { } catch (e) { res .status(e.code === "ENOENT" ? 404 : 500) - .json({ error: e.message, code: e.code }); + .json({ error: e.code || "internal", code: e.code }); } }); @@ -213,7 +213,7 @@ router.post("/writeFile", async (req, res) => { invalidateBootstrap(req); res.json({ ok: true, mtime: result.mtime, size: result.size }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -231,7 +231,7 @@ router.post("/appendFile", async (req, res) => { invalidateBootstrap(req); res.json({ ok: true }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -251,7 +251,7 @@ router.post("/mkdir", async (req, res) => { invalidateBootstrap(req); res.json({ ok: true }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -280,7 +280,7 @@ router.post("/rename", async (req, res) => { invalidateBootstrap(req); res.json({ ok: true }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -309,7 +309,7 @@ router.post("/copyFile", async (req, res) => { invalidateBootstrap(req); res.json({ ok: true }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -331,7 +331,7 @@ router.delete("/unlink", async (req, res) => { // File already gone - desired outcome achieved res.json({ ok: true }); } else { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } } }); @@ -350,7 +350,7 @@ router.delete("/rmdir", async (req, res) => { invalidateBootstrap(req); res.json({ ok: true }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -370,7 +370,7 @@ router.delete("/rm", async (req, res) => { invalidateBootstrap(req); res.json({ ok: true }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -388,7 +388,7 @@ router.get("/access", async (req, res) => { } catch (e) { res .status(e.code === "ENOENT" ? 404 : 500) - .json({ error: e.message, code: e.code }); + .json({ error: e.code || "internal", code: e.code }); } }); @@ -404,7 +404,7 @@ router.get("/realpath", async (req, res) => { res.json({ path: path.relative(req._vaultRoot, real) }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -426,7 +426,7 @@ router.post("/utimes", async (req, res) => { invalidateBootstrap(req); res.json({ ok: true }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -441,6 +441,11 @@ router.post("/batch-read", async (req, res) => { const paths = Array.isArray(req.body?.paths) ? req.body.paths : []; + // The indexer prefetcher (the only caller) batches at 50, so a much larger list is not legitimate. + if (paths.length > 1000) { + return res.status(400).json({ error: "too many paths in batch-read" }); + } + if (paths.length === 0) { return res.json({ files: {} }); } @@ -531,7 +536,7 @@ router.get("/tree", async (req, res) => { res.json(tree); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -561,7 +566,7 @@ router.get("/download", async (req, res) => { } catch (e) { res .status(e.code === "ENOENT" ? 404 : 500) - .json({ error: e.message, code: e.code }); + .json({ error: e.code || "internal", code: e.code }); } }); @@ -599,7 +604,7 @@ router.get("/download-zip", async (req, res) => { } catch (e) { res .status(e.code === "ENOENT" ? 404 : 500) - .json({ error: e.message, code: e.code }); + .json({ error: e.code || "internal", code: e.code }); } }); diff --git a/apps/ignis-server/server/routes/vault.js b/apps/ignis-server/server/routes/vault.js index 438298b..31f65ce 100644 --- a/apps/ignis-server/server/routes/vault.js +++ b/apps/ignis-server/server/routes/vault.js @@ -6,6 +6,25 @@ const bootstrapRoutes = require("./bootstrap"); const router = express.Router(); +// Vault names become directories under VAULT_ROOT; reject traversal, hidden, and reserved-device names. +const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i; + +function isValidVaultName(name) { + if (typeof name !== "string" || name.length === 0 || name.length > 255) { + return false; + } + + if (/[\/\\:*?"<>|]/.test(name)) { + return false; + } + + if (name.startsWith(".")) { + return false; + } + + return !WINDOWS_RESERVED.test(name); +} + // GET /api/vault/list - returns all discovered vaults (re-scans on each call) router.get("/list", (req, res) => { config.refreshVaults(); @@ -41,7 +60,7 @@ router.get("/info", async (req, res) => { router.post("/create", async (req, res) => { const name = req.body?.name; - if (!name || /[\/\\:*?"<>|]/.test(name)) { + if (!isValidVaultName(name)) { return res.status(400).json({ error: "Invalid vault name" }); } @@ -62,7 +81,7 @@ router.post("/create", async (req, res) => { return res.status(409).json({ error: "Vault already exists" }); } - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -71,7 +90,7 @@ router.post("/rename", async (req, res) => { const vaultId = req.body?.vault; const newName = req.body?.name; - if (!newName || /[\/\\:*?"<>|]/.test(newName)) { + if (!isValidVaultName(newName)) { return res.status(400).json({ error: "Invalid vault name" }); } @@ -98,7 +117,7 @@ router.post("/rename", async (req, res) => { .json({ error: "A vault with that name already exists" }); } - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); @@ -119,7 +138,7 @@ router.delete("/remove", async (req, res) => { res.json({ ok: true }); } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + res.status(500).json({ error: e.code || "internal", code: e.code }); } }); diff --git a/packages/shim/src/fs/fd.js b/packages/shim/src/fs/fd.js index fadfd78..213945f 100644 --- a/packages/shim/src/fs/fd.js +++ b/packages/shim/src/fs/fd.js @@ -1,9 +1,8 @@ -// File descriptor shim - maps fake integer fds to in-memory file buffers. -// Enables libraries like yauzl that use fs.open/fs.read/fs.close to seek -// around files without loading them via readFileSync upfront. +// File descriptor shim. Maps fake integer fds to in-memory file buffers. import { isInputCachePath, inputCacheGet } from "./input-cache.js"; import { resolvePath } from "./transforms.js"; +import { hasVirtualFile, getVirtualFile } from "./virtual-files.js"; let nextFd = 100; const openFiles = new Map(); @@ -24,6 +23,15 @@ export function createFdOps(metadataCache, contentCache, transport) { } const resolved = resolvePath(path); + + if (hasVirtualFile(resolved)) { + const content = getVirtualFile(resolved); + + return typeof content === "string" + ? new TextEncoder().encode(content) + : content; + } + const cached = contentCache.get(resolved); if (cached !== null) { @@ -60,7 +68,11 @@ export function createFdOps(metadataCache, contentCache, transport) { const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; const resolved = resolvePath(path); - if (!hasInCache && !metadataCache.has(resolved)) { + if ( + !hasInCache && + !hasVirtualFile(resolved) && + !metadataCache.has(resolved) + ) { const err = new Error( `ENOENT: no such file or directory, open '${path}'`, ); diff --git a/packages/shim/src/fs/sync.js b/packages/shim/src/fs/sync.js index f46642a..2db6f9b 100644 --- a/packages/shim/src/fs/sync.js +++ b/packages/shim/src/fs/sync.js @@ -5,6 +5,7 @@ import { applyWriteTransform, resolvePath, } from "./transforms.js"; +import { hasVirtualFile, getVirtualFile } from "./virtual-files.js"; export function createFsSync(metadataCache, contentCache, transport) { return { @@ -70,6 +71,21 @@ export function createFsSync(metadataCache, contentCache, transport) { const wantText = encoding === "utf8" || encoding === "utf-8"; const resolved = resolvePath(path); + // Virtual plugin source overrides any cache or transport version. + if (hasVirtualFile(resolved)) { + const content = getVirtualFile(resolved); + + if (wantText) { + return typeof content === "string" + ? content + : new TextDecoder().decode(content); + } + + return typeof content === "string" + ? new TextEncoder().encode(content) + : content; + } + const meta = metadataCache.get(resolved); if (meta && meta.type === "directory") { const e = new Error("EISDIR: illegal operation on a directory, read"); diff --git a/packages/shim/src/util/url.js b/packages/shim/src/util/url.js index 7c6071a..6e00539 100644 --- a/packages/shim/src/util/url.js +++ b/packages/shim/src/util/url.js @@ -2,7 +2,7 @@ function isSameOrigin(url) { if ( !url || - url.startsWith("/") || + (url.startsWith("/") && !url.startsWith("//")) || url.startsWith("./") || url.startsWith("../") ) { diff --git a/packages/shim/src/util/url.test.js b/packages/shim/src/util/url.test.js new file mode 100644 index 0000000..de1b94d --- /dev/null +++ b/packages/shim/src/util/url.test.js @@ -0,0 +1,25 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { isSameOrigin } from "./url.js"; + +describe("isSameOrigin", () => { + beforeEach(() => { + global.window = { location: { origin: "https://vault.example.com" } }; + }); + + afterEach(() => { + delete global.window; + }); + + it("treats a root-relative path as same-origin", () => { + expect(isSameOrigin("/api/fs/readFile")).toBe(true); + }); + + it("treats a protocol-relative URL as cross-origin", () => { + expect(isSameOrigin("//evil.com/x")).toBe(false); + }); + + it("matches the page origin and rejects a different host", () => { + expect(isSameOrigin("https://vault.example.com/x")).toBe(true); + expect(isSameOrigin("https://evil.com/x")).toBe(false); + }); +});