diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d53cee..61ae76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to this project will be documented in this file. +## [0.8.6] - Karm (2026-06-12) + +### Added + +- `OBSIDIAN_PACKAGE` env var: unpack a pre-placed `.deb`, `.asar.gz`, or `.asar` on first run instead of downloading, for offline or restricted networks. +- `PROXY_ALLOW_PRIVATE_HOSTS` env var: IPs or IPv4 CIDRs the cross-origin proxy may reach despite the private-address block. + +### Changed + +- `fs.promises.realpath` is answered from the client-side cache; vault load no longer issues one realpath request per folder. + +### Fixed + +- Sync file reads serve virtual plugin files the same as async reads. + +### Security + +- Cross-origin proxy rewritten for better security +- Filesystem and vault error responses no longer include absolute server paths. +- Protocol-relative (`//host`) requests route through the proxy guard. +- Vault names are validated on creation; `batch-read` caps the number of paths per request. +- Demo mode: `/api/ext/*` blocked, and several security fixes +- The `ob` CLI is spawned without a shell. +- Dependency bumps clearing npm audit. + ## [0.8.5] - Karm (2026-06-07) ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f7c338..b4ba637 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,18 +46,22 @@ This kind of report makes it straightforward to add the missing shim. If you want to contribute code: 1. Fork the repo and create a branch for your change -2. Run `npm run build` to verify everything builds -3. start the server with `npm run dev`. +2. Run `npm install` once at the repo root (npm workspaces) +3. Run `npm run dev` to build and start the server 4. Test your change in the browser with at least one vault open -5. Keep PRs focused - one fix or feature per PR +5. Run `npm test` and make sure the whole suite passes +6. Keep PRs focused - one fix or feature per PR + +Changes to deliberate behavior (the fs shim's caching and write model, the proxy's request handling, anything documented as a design decision) start as an issue, not a PR. Open the issue first so the approach can be discussed; a patch against an undiscussed design change will be closed on this basis. ### Project structure -- `src/shims/` - Browser shims for Node.js and Electron APIs -- `src/ui/` - Svelte UI components (vault manager, dialogs) -- `plugin/` - The ignis-bridge Obsidian plugin (settings, file actions) -- `server/` - Express server (fs routes, WebSocket, plugin system) -- `server/plugins/` - Server plugin packages (e.g., headless-sync) +- `packages/shim/` - Browser shims for Node.js and Electron APIs +- `packages/ui/` - Svelte UI components (vault manager, dialogs) +- `packages/bridge/` - The ignis-bridge Obsidian plugin (settings, file actions) +- `packages/server-core/` - Shared server helpers (path guards, watcher, WebSocket) +- `apps/ignis-server/` - Express server, Docker image, demo mode +- `apps/ignis-server/server/plugins/` - Server plugin packages (e.g., headless-sync) See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail. @@ -65,7 +69,7 @@ See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail. If a plugin needs a Node.js module that isn't shimmed: -1. Create the shim in `src/shims/node/.js` +1. Create the shim in `packages/shim/src/node/.js` 2. Export the functions the plugin needs (stub what you can't implement) -3. Register it in `src/shims/require.js` (import + add to `rawRegistry`) +3. Register it in `packages/shim/src/require.js` (import + add to `rawRegistry`) 4. Build and test with the plugin that needed it diff --git a/apps/ignis-server/README.md b/apps/ignis-server/README.md index 22b26dc..fc5b13c 100644 --- a/apps/ignis-server/README.md +++ b/apps/ignis-server/README.md @@ -75,14 +75,29 @@ To build from source instead of pulling the image, clone the repo and run `docke | `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` | | `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` | | `OBSIDIAN_ASSETS_PATH` | Where the extracted Obsidian app files live. Override if you're pointing at a pre-extracted directory instead of letting the entrypoint download. | `/app/obsidian-app` | +| `OBSIDIAN_PACKAGE` | Path to a pre-placed Obsidian package to unpack on first run instead of downloading, for offline or restricted networks. Accepts `.deb` (the form obsidian.md distributes), `.asar.gz`, or `.asar`. | unset | | `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` | | `PUID` | User ID for file ownership | `1000` | | `PGID` | Group ID for file ownership | `1000` | | `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` | | `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset | +| `PROXY_ALLOW_PRIVATE_HOSTS` | Comma-separated IPs or IPv4 CIDRs the cross-origin proxy may reach despite the private-address block, for LAN services. Matched against the resolved IP. Reopens SSRF to the listed targets. | unset | Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [`examples/demo/`](examples/demo/) if you want to run a public demo deployment. +## Offline / restricted-network install + +If the container can't reach GitHub on first run (air-gapped or restricted networks), download Obsidian yourself from [obsidian.md](https://obsidian.md/download) (the `.deb`), mount it into the container, and point `OBSIDIAN_PACKAGE` at it: + +```yaml + volumes: + - ./obsidian_1.12.7_amd64.deb:/packages/obsidian.deb:ro + environment: + - OBSIDIAN_PACKAGE=/packages/obsidian.deb +``` + +On first run the entrypoint unpacks that instead of downloading. Match the version this release pins (see the OCI label and CHANGELOG); a mismatch logs a warning and still boots. `.asar.gz` and `.asar` are also accepted. + ## Migrating an existing vault Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis. diff --git a/apps/ignis-server/scripts/build-image.js b/apps/ignis-server/scripts/build-image.js new file mode 100644 index 0000000..c9ff5b9 --- /dev/null +++ b/apps/ignis-server/scripts/build-image.js @@ -0,0 +1,111 @@ +// node build-image.js [--push] [--no-latest] +// +// --push build mulit-arch (amd64+arm64) and push as a manifest list, tagged with the package.json version and latest +// --no-latest don't move the latest tag +// +// Without --push, builds the host arch and loads it as :dev. + +const { spawnSync, execSync } = require("child_process"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, "..", "..", ".."); +const IMAGE = process.env.IGNIS_IMAGE || "nobbe/ignis"; +const BUILDER = "ignis-builder"; +const PLATFORMS = "linux/amd64,linux/arm64"; + +const args = process.argv.slice(2); +const push = args.includes("--push"); +const noLatest = args.includes("--no-latest"); +const unknown = args.filter((a) => a !== "--push" && a !== "--no-latest"); + +if (unknown.length > 0) { + console.error("[build-image] unknown arguments:", unknown.join(" ")); + console.error("usage: node build-image.js [--push] [--no-latest]"); + process.exit(1); +} + +const version = require(path.join(repoRoot, "package.json")).version; + +if (!/^\d+\.\d+\.\d+$/.test(version)) { + console.error( + `[build-image] version "${version}" is not plain X.Y.Z, refusing to tag`, + ); + process.exit(1); +} + +function run(cmd, cmdArgs, opts = {}) { + const result = spawnSync(cmd, cmdArgs, { + cwd: repoRoot, + stdio: "inherit", + ...opts, + }); + + return result.status === 0; +} + +let dirty = ""; + +try { + dirty = execSync("git status --porcelain", { cwd: repoRoot }) + .toString() + .trim(); +} catch { + console.warn("[build-image] could not check git status"); +} + +if (dirty) { + console.warn( + "[build-image] WARNING: working tree has uncommitted changes; the image will not match the committed source", + ); +} + +const inspect = spawnSync("docker", ["buildx", "inspect", BUILDER], { + stdio: "ignore", +}); + +if (inspect.status !== 0) { + console.log(`[build-image] creating buildx builder ${BUILDER}`); + + const created = run("docker", [ + "buildx", + "create", + "--name", + BUILDER, + "--driver", + "docker-container", + ]); + + if (!created) { + process.exit(1); + } +} + +const buildArgs = [ + "buildx", + "build", + "--builder", + BUILDER, + "-f", + "apps/ignis-server/Dockerfile", +]; + +if (push) { + buildArgs.push("--platform", PLATFORMS, "-t", `${IMAGE}:${version}`); + + if (!noLatest) { + buildArgs.push("-t", `${IMAGE}:latest`); + } + + buildArgs.push("--push"); + console.log( + `[build-image] building ${PLATFORMS} and pushing ${IMAGE}:${version}${noLatest ? "" : ` + ${IMAGE}:latest`}`, + ); +} else { + // Host arch only. Multi-arch builds can't be loaded into the local image store. + buildArgs.push("-t", `${IMAGE}:dev`, "--load"); + console.log(`[build-image] local build, loading ${IMAGE}:dev`); +} + +buildArgs.push("."); + +process.exit(run("docker", buildArgs) ? 0 : 1); diff --git a/apps/ignis-server/scripts/entrypoint.sh b/apps/ignis-server/scripts/entrypoint.sh index 4bb473c..8b64667 100644 --- a/apps/ignis-server/scripts/entrypoint.sh +++ b/apps/ignis-server/scripts/entrypoint.sh @@ -30,19 +30,66 @@ chown -R "$PUID:$PGID" /vaults /app/obsidian-app /app/data OBSIDIAN_DIR="/app/obsidian-app" OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}" +warn_obsidian_version() { + if [ -n "$1" ] && [ "$1" != "$OBSIDIAN_VERSION" ]; then + echo "[ignis] WARNING: package is Obsidian $1, but this build is pinned to ${OBSIDIAN_VERSION}. The shim may misbehave." + fi +} + if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then - echo "[ignis] First run. Downloading Obsidian v${OBSIDIAN_VERSION}..." + if [ -n "$OBSIDIAN_PACKAGE" ]; then + # Offline / restricted networks: unpack an operator-supplied package instead of downloading. + if [ ! -f "$OBSIDIAN_PACKAGE" ]; then + echo "[ignis] ERROR: OBSIDIAN_PACKAGE='$OBSIDIAN_PACKAGE' but that file does not exist." + exit 1 + fi - curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian-${OBSIDIAN_VERSION}.asar.gz" \ - -o /tmp/obsidian.asar.gz + echo "[ignis] First run. Unpacking local Obsidian package: $OBSIDIAN_PACKAGE" - echo "[ignis] Unpacking asar..." - gunzip /tmp/obsidian.asar.gz - npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR" + case "$OBSIDIAN_PACKAGE" in + *.deb) + warn_obsidian_version "$(dpkg-deb -f "$OBSIDIAN_PACKAGE" Version 2>/dev/null)" + rm -rf /tmp/ob-deb + dpkg-deb -x "$OBSIDIAN_PACKAGE" /tmp/ob-deb + npx --yes @electron/asar extract \ + /tmp/ob-deb/opt/Obsidian/resources/obsidian.asar "$OBSIDIAN_DIR" + rm -rf /tmp/ob-deb + ;; + *.asar.gz) + warn_obsidian_version "$(basename "$OBSIDIAN_PACKAGE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" + cp "$OBSIDIAN_PACKAGE" /tmp/obsidian.asar.gz + gunzip -f /tmp/obsidian.asar.gz + npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR" + rm -f /tmp/obsidian.asar + ;; + *.asar) + warn_obsidian_version "$(basename "$OBSIDIAN_PACKAGE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" + npx --yes @electron/asar extract "$OBSIDIAN_PACKAGE" "$OBSIDIAN_DIR" + ;; + *) + echo "[ignis] ERROR: unsupported OBSIDIAN_PACKAGE format. Supported: .deb, .asar.gz, .asar" + exit 1 + ;; + esac + else + echo "[ignis] First run. Downloading Obsidian v${OBSIDIAN_VERSION}..." - rm -f /tmp/obsidian.asar + curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian-${OBSIDIAN_VERSION}.asar.gz" \ + -o /tmp/obsidian.asar.gz - echo "[ignis] Obsidian v${OBSIDIAN_VERSION} ready." + echo "[ignis] Unpacking asar..." + gunzip /tmp/obsidian.asar.gz + npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR" + + rm -f /tmp/obsidian.asar + fi + + if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then + echo "[ignis] ERROR: setup did not produce $OBSIDIAN_DIR/index.html; the Obsidian package may be invalid." + exit 1 + fi + + echo "[ignis] Obsidian ready (v${OBSIDIAN_VERSION})." else echo "[ignis] Obsidian already set up." fi diff --git a/apps/ignis-server/server/demo/demo-provision.js b/apps/ignis-server/server/demo/demo-provision.js index a4840d8..a361288 100644 --- a/apps/ignis-server/server/demo/demo-provision.js +++ b/apps/ignis-server/server/demo/demo-provision.js @@ -80,6 +80,14 @@ async function provisionVault(sessionId, userVaultName) { const storageName = makeStorageName(sessionId, userVaultName); const vaultPath = path.join(config.vaultRoot, storageName); + // keep the resolved path inside the vault root. + const root = path.resolve(config.vaultRoot); + const resolved = path.resolve(vaultPath); + + if (resolved !== root && !resolved.startsWith(root + path.sep)) { + return { error: "invalid-vault-name" }; + } + await fsp.mkdir(config.vaultRoot, { recursive: true }); try { diff --git a/apps/ignis-server/server/demo/demo-sessions.js b/apps/ignis-server/server/demo/demo-sessions.js index b39c421..3b35690 100644 --- a/apps/ignis-server/server/demo/demo-sessions.js +++ b/apps/ignis-server/server/demo/demo-sessions.js @@ -16,6 +16,13 @@ function newSessionId() { return crypto.randomBytes(12).toString("hex"); } +// accept only the format we issue. +const SESSION_ID_RE = /^[a-f0-9]{24}$/; + +function isValidSessionId(id) { + return typeof id === "string" && SESSION_ID_RE.test(id); +} + function prefixFor(sessionId) { return "demo-" + sessionId + PREFIX_SEPARATOR; } @@ -61,20 +68,25 @@ function setSessionCookie(res, sessionId) { res.setHeader( "Set-Cookie", - `${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}`, + `${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAgeSeconds}`, ); } // Resolve the session for a request. If none exists, create one (unless options.peek is true). function getOrCreateSession(req, res, options = {}) { const cookies = parseCookies(req); - const existing = cookies[COOKIE_NAME]; + const raw = cookies[COOKIE_NAME]; + const existing = isValidSessionId(raw) ? raw : null; if (existing && sessions.has(existing)) { return existing; } if (existing && !sessions.has(existing)) { + if (sessions.size >= config.demoMaxSessions) { + return null; + } + // Cookie outlived in-memory session. reuse the id to keep the prefix. sessions.set(existing, { lastActivity: Date.now(), diff --git a/apps/ignis-server/server/demo/demo-ws.js b/apps/ignis-server/server/demo/demo-ws.js index 6054464..e3eae83 100644 --- a/apps/ignis-server/server/demo/demo-ws.js +++ b/apps/ignis-server/server/demo/demo-ws.js @@ -10,6 +10,7 @@ const { sessions, parseCookies, makeStorageName, + tryParseUserVaultName, touchSession, } = require("./demo-sessions"); @@ -28,6 +29,20 @@ function wireWebSocket(server) { if (userVault && !userVault.startsWith("demo-")) { u.searchParams.set("vault", makeStorageName(sessionId, userVault)); req.url = u.pathname + u.search; + } else if ( + userVault && + userVault.startsWith("demo-") && + tryParseUserVaultName(sessionId, userVault) === null + ) { + // An already-prefixed vault that isn't this session's: refuse the upgrade. + const socket = rest[0]; + + if (socket && socket.writable) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + } + + return; } touchSession(sessionId); diff --git a/apps/ignis-server/server/demo/index.js b/apps/ignis-server/server/demo/index.js index 11f1b3a..54bd592 100644 --- a/apps/ignis-server/server/demo/index.js +++ b/apps/ignis-server/server/demo/index.js @@ -70,6 +70,11 @@ function setupDemo(app) { // Hide server-side plugins (headless-sync) from the demo UI app.use("/api/plugins", pluginsBlocker); + // Plugin routes are not exposed in demo mode. + app.use("/api/ext", (req, res) => { + res.status(403).json({ error: "Plugin routes are disabled in demo mode" }); + }); + // Server settings are-fixed in demo mode. app.use("/api/settings", (req, res) => { res.status(403).json({ error: "Settings are disabled in demo mode" }); diff --git a/apps/ignis-server/server/plugins/headless-sync/ob-cli.js b/apps/ignis-server/server/plugins/headless-sync/ob-cli.js index 5001905..83788cb 100644 --- a/apps/ignis-server/server/plugins/headless-sync/ob-cli.js +++ b/apps/ignis-server/server/plugins/headless-sync/ob-cli.js @@ -3,8 +3,6 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); -const isWindows = process.platform === "win32"; - // When set via configure(), HOME for the spawned ob points under the plugin's data dir so // ob's config dir (~/.config/obsidian-headless/) survives container recreates. let configuredDataDir = null; @@ -39,13 +37,11 @@ function checkInstalled() { } function spawnOb(args, opts = {}) { - const home = configuredDataDir - ? getObHome(configuredDataDir) - : os.homedir(); + const home = configuredDataDir ? getObHome(configuredDataDir) : os.homedir(); return spawn("ob", args, { env: { ...process.env, HOME: home }, - shell: isWindows, + shell: false, windowsHide: true, ...opts, }); diff --git a/apps/ignis-server/server/routes/fs.js b/apps/ignis-server/server/routes/fs.js index 36654b7..10ddb3e 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,23 +388,7 @@ router.get("/access", async (req, res) => { } catch (e) { res .status(e.code === "ENOENT" ? 404 : 500) - .json({ error: e.message, code: e.code }); - } -}); - -router.get("/realpath", async (req, res) => { - const resolved = guardPath(req, res); - - if (!resolved) { - return; - } - - try { - const real = await fs.promises.realpath(resolved); - - res.json({ path: path.relative(req._vaultRoot, real) }); - } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); + .json({ error: e.code || "internal", code: e.code }); } }); @@ -426,7 +410,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 +425,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 +520,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 +550,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 +588,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/proxy.js b/apps/ignis-server/server/routes/proxy.js index 4b5fb97..4e92fa2 100644 --- a/apps/ignis-server/server/routes/proxy.js +++ b/apps/ignis-server/server/routes/proxy.js @@ -1,11 +1,16 @@ const express = require("express"); -const dns = require("dns").promises; +const dns = require("dns"); const net = require("net"); +const http = require("http"); +const https = require("https"); +const zlib = require("zlib"); const settings = require("../settings"); const router = express.Router(); const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; +const MAX_REDIRECTS = 5; +const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]); function isPrivateIp(ip) { const type = net.isIP(ip); @@ -47,13 +52,116 @@ function isPrivateIp(ip) { return false; } +function ipv4ToInt(ip) { + return ip + .split(".") + .reduce((acc, oct) => ((acc << 8) + Number(oct)) >>> 0, 0); +} + +// Parse PROXY_ALLOW_PRIVATE_HOSTS into matchers. +// Exact IPs (v4 and v6) and IPv4 CIDRs are supported; IPv6 CIDR and malformed entries are ignored. +function buildAllowList(entries) { + const exact = new Set(); + const cidrV4 = []; + + for (const entry of entries) { + const slash = entry.indexOf("/"); + + if (slash === -1) { + if (net.isIP(entry)) { + exact.add(entry); + } else { + console.warn( + "[proxy] ignoring invalid PROXY_ALLOW_PRIVATE_HOSTS entry:", + entry, + ); + } + + continue; + } + + const base = entry.slice(0, slash); + const prefix = Number(entry.slice(slash + 1)); + + if ( + net.isIP(base) === 4 && + Number.isInteger(prefix) && + prefix >= 0 && + prefix <= 32 + ) { + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + cidrV4.push({ network: (ipv4ToInt(base) & mask) >>> 0, mask }); + } else { + console.warn( + "[proxy] ignoring unsupported PROXY_ALLOW_PRIVATE_HOSTS entry:", + entry, + ); + } + } + + return { exact, cidrV4 }; +} + +function allowsAddress(allow, ip) { + if (allow.exact.has(ip)) { + return true; + } + + if (net.isIP(ip) === 4) { + const value = ipv4ToInt(ip); + + for (const { network, mask } of allow.cidrV4) { + if ((value & mask) >>> 0 === network) { + return true; + } + } + } + + return false; +} + +const privateAllowList = buildAllowList(settings.get("proxyAllowPrivate")); + +// A public address always passes; a private one passes only when listed it in PROXY_ALLOW_PRIVATE_HOSTS. +function addressAllowed(ip) { + return !isPrivateIp(ip) || allowsAddress(privateAllowList, ip); +} + function httpError(status, message) { const e = new Error(message); e.statusCode = status; return e; } -// Reject non-http(s) schemes and hosts that resolve to a private or link-local address. +function safeLookup(hostname, options, callback) { + dns.lookup(hostname, { ...options, all: true }, (err, addresses) => { + if (err) { + callback(err); + return; + } + + if (!addresses.length) { + callback(httpError(502, "DNS resolution failed")); + return; + } + + for (const a of addresses) { + if (!addressAllowed(a.address)) { + callback(httpError(403, "Host resolves to a private address")); + return; + } + } + + if (options && options.all) { + callback(null, addresses); + return; + } + + callback(null, addresses[0].address, addresses[0].family); + }); +} + +// Reject non-http(s) schemes and hosts that resolve to a disallowed address. async function assertPublicUrl(urlStr) { let parsed; @@ -70,7 +178,7 @@ async function assertPublicUrl(urlStr) { const host = parsed.hostname; if (net.isIP(host)) { - if (isPrivateIp(host)) { + if (!addressAllowed(host)) { throw httpError(403, "Host not allowed"); } @@ -80,18 +188,161 @@ async function assertPublicUrl(urlStr) { let addrs; try { - addrs = await dns.lookup(host, { all: true }); + addrs = await dns.promises.lookup(host, { all: true }); } catch { throw httpError(502, "DNS resolution failed"); } for (const a of addrs) { - if (isPrivateIp(a.address)) { + if (!addressAllowed(a.address)) { throw httpError(403, "Host resolves to a private address"); } } } +function sameOrigin(a, b) { + return a.protocol === b.protocol && a.host === b.host; +} + +function requestOnce(targetUrl, method, headers, body) { + return new Promise((resolve, reject) => { + const mod = targetUrl.protocol === "https:" ? https : http; + const req = mod.request( + targetUrl, + { method, headers, lookup: safeLookup }, + resolve, + ); + + req.on("error", reject); + + if (body && method !== "GET" && method !== "HEAD") { + req.write(body); + } + + req.end(); + }); +} + +// Follow redirects manually so every hop runs through safeLookup and is re-checked. +async function proxyRequest({ url, method, headers, body }) { + let current = new URL(url); + let currentMethod = method; + let currentHeaders = headers; + let currentBody = body; + + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + if (current.protocol !== "http:" && current.protocol !== "https:") { + throw httpError(400, "Only http and https URLs are allowed"); + } + + // An IP-literal host skips DNS, so safeLookup never runs for it; check it here. + if (net.isIP(current.hostname) && !addressAllowed(current.hostname)) { + throw httpError(403, "Host not allowed"); + } + + const res = await requestOnce( + current, + currentMethod, + currentHeaders, + currentBody, + ); + + if (!REDIRECT_CODES.has(res.statusCode) || !res.headers.location) { + return res; + } + + res.resume(); + const next = new URL(res.headers.location, current); + + // The caller did not choose the redirect target, so credentials do not cross origins. + if (!sameOrigin(current, next)) { + currentHeaders = { ...currentHeaders }; + + for (const key of Object.keys(currentHeaders)) { + const lower = key.toLowerCase(); + + if (lower === "authorization" || lower === "cookie") { + delete currentHeaders[key]; + } + } + } + + // 301/302/303 turn a non-GET follow-up into a GET; 307/308 preserve method and body. + if (res.statusCode !== 307 && res.statusCode !== 308) { + if (currentMethod !== "GET" && currentMethod !== "HEAD") { + currentMethod = "GET"; + currentBody = null; + } + } + + current = next; + } + + throw httpError(508, "Too many redirects"); +} + +function readBody(res, maxBytes) { + return new Promise((resolve, reject) => { + const encoding = (res.headers["content-encoding"] || "").toLowerCase(); + let stream = res; + let decompressor = null; + + if (encoding === "gzip" || encoding === "x-gzip") { + decompressor = zlib.createGunzip(); + } else if (encoding === "deflate") { + decompressor = zlib.createInflate(); + } else if (encoding === "br") { + decompressor = zlib.createBrotliDecompress(); + } + + if (decompressor) { + stream = res.pipe(decompressor); + } + + const chunks = []; + let total = 0; + let settled = false; + + function fail(err) { + if (settled) { + return; + } + + settled = true; + res.destroy(); + + if (decompressor) { + decompressor.destroy(); + } + + reject(err); + } + + stream.on("data", (chunk) => { + total += chunk.length; + + if (total > maxBytes) { + fail(httpError(413, "Upstream response too large")); + return; + } + + chunks.push(chunk); + }); + + stream.on("end", () => { + if (settled) { + return; + } + + settled = true; + resolve(Buffer.concat(chunks)); + }); + + stream.on("error", (e) => fail(httpError(502, e.message))); + res.on("error", (e) => fail(httpError(502, e.message))); + }); +} + // POST /api/proxy - forward a request to an external URL to bypass CORS. router.post("/", async (req, res) => { const { url, method, headers, body, binary } = req.body; @@ -124,40 +375,29 @@ router.post("/", async (req, res) => { } try { - // Forward the caller's headers as-is. - const fetchOpts = { + const reqBody = + binary && typeof body === "string" ? Buffer.from(body, "base64") : body; + + const upstream = await proxyRequest({ + url, method: method || "GET", headers: headers || {}, - }; + body: reqBody, + }); - if (body && method !== "GET" && method !== "HEAD") { - if (binary && typeof body === "string") { - fetchOpts.body = Buffer.from(body, "base64"); - } else { - fetchOpts.body = body; - } - } - - const upstream = await fetch(url, fetchOpts); - - const declaredLength = Number(upstream.headers.get("content-length")); + const declaredLength = Number(upstream.headers["content-length"]); if ( Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES ) { + upstream.destroy(); return res.status(413).json({ error: "Upstream response too large" }); } - const respArrayBuf = await upstream.arrayBuffer(); + const respBody = await readBody(upstream, MAX_RESPONSE_BYTES); - if (respArrayBuf.byteLength > MAX_RESPONSE_BYTES) { - return res.status(413).json({ error: "Upstream response too large" }); - } - - const respBody = Buffer.from(respArrayBuf); - - // Strip hop-by-hop / encoding headers since the body is already decompressed. + // Strip hop-by-hop and encoding headers; the body is already decompressed. const skipHeaders = new Set([ "content-encoding", "transfer-encoding", @@ -166,21 +406,24 @@ router.post("/", async (req, res) => { ]); const respHeaders = {}; - upstream.headers.forEach((val, key) => { - if (!skipHeaders.has(key)) { + for (const [key, val] of Object.entries(upstream.headers)) { + if (!skipHeaders.has(key.toLowerCase())) { respHeaders[key] = val; } - }); + } res.json({ - status: upstream.status, + status: upstream.statusCode, headers: respHeaders, body: respBody.toString("base64"), }); } catch (e) { - res.status(502).json({ error: e.message }); + res.status(e.statusCode || 502).json({ error: e.message }); } }); module.exports = router; module.exports.isPrivateIp = isPrivateIp; +module.exports.proxyRequest = proxyRequest; +module.exports.buildAllowList = buildAllowList; +module.exports.allowsAddress = allowsAddress; diff --git a/apps/ignis-server/server/routes/proxy.test.mjs b/apps/ignis-server/server/routes/proxy.test.mjs index 6ddd3d2..c9c9c80 100644 --- a/apps/ignis-server/server/routes/proxy.test.mjs +++ b/apps/ignis-server/server/routes/proxy.test.mjs @@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "module"; const require = createRequire(import.meta.url); -const { isPrivateIp } = require("./proxy.js"); +const { isPrivateIp, proxyRequest, buildAllowList, allowsAddress } = + require("./proxy.js"); describe("isPrivateIp", () => { it("flags private and link-local IPv4", () => { @@ -60,3 +61,38 @@ describe("isPrivateIp", () => { expect(isPrivateIp("")).toBe(false); }); }); + +describe("proxyRequest guard", () => { + it("rejects a hostname that resolves to a private address", async () => { + await expect( + proxyRequest({ url: "http://localhost/", method: "GET", headers: {} }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it("rejects a private IP literal (no DNS lookup runs for literals)", async () => { + await expect( + proxyRequest({ url: "http://127.0.0.1/", method: "GET", headers: {} }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); +}); + +describe("proxy private-host allow list", () => { + it("allows exact IPs and IPv4 CIDRs, rejects everything else", () => { + const allow = buildAllowList(["192.168.0.0/16", "10.1.2.3", "::1"]); + + expect(allowsAddress(allow, "192.168.1.5")).toBe(true); + expect(allowsAddress(allow, "192.169.0.1")).toBe(false); + expect(allowsAddress(allow, "10.1.2.3")).toBe(true); + expect(allowsAddress(allow, "10.1.2.4")).toBe(false); + expect(allowsAddress(allow, "::1")).toBe(true); + expect(allowsAddress(allow, "8.8.8.8")).toBe(false); + }); + + it("ignores IPv6 CIDR and malformed entries", () => { + const allow = buildAllowList(["fd00::/8", "garbage", "192.168.0.0/33"]); + + expect(allow.exact.size).toBe(0); + expect(allow.cidrV4.length).toBe(0); + expect(allowsAddress(allow, "fd00::1")).toBe(false); + }); +}); 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/apps/ignis-server/server/settings.js b/apps/ignis-server/server/settings.js index 854d06d..7c1644c 100644 --- a/apps/ignis-server/server/settings.js +++ b/apps/ignis-server/server/settings.js @@ -17,6 +17,8 @@ const DEFAULTS = { // Empty allows any public host. proxyAllowlist: [], wsOrigins: [], + // Private IPs/CIDRs the proxy may reach despite the SSRF guard. + proxyAllowPrivate: [], }; const PROXY_MODES = ["any", "allowlist", "disabled"]; @@ -24,7 +26,7 @@ const PROXY_MODES = ["any", "allowlist", "disabled"]; const KEYS = Object.keys(DEFAULTS); // Env vars only; never persisted to the settings file. -const ENV_ONLY_KEYS = ["wsOrigins"]; +const ENV_ONLY_KEYS = ["wsOrigins", "proxyAllowPrivate"]; // Hard ceiling for request bodies. const MAX_BODY_BACKSTOP = 500 * 1024 * 1024; @@ -51,6 +53,10 @@ function fromEnv() { env.wsOrigins = parseList(process.env.WS_ORIGINS); } + if (process.env.PROXY_ALLOW_PRIVATE_HOSTS) { + env.proxyAllowPrivate = parseList(process.env.PROXY_ALLOW_PRIVATE_HOSTS); + } + return env; } diff --git a/package-lock.json b/package-lock.json index f0d4a48..1613631 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ignis-monorepo", - "version": "0.8.2", + "version": "0.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ignis-monorepo", - "version": "0.8.2", + "version": "0.8.5", "workspaces": [ "packages/*", "apps/*" @@ -1006,15 +1006,15 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -1023,13 +1023,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -1050,9 +1050,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", "dev": true, "license": "MIT", "dependencies": { @@ -1063,13 +1063,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -1078,13 +1078,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -1093,9 +1093,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1106,13 +1106,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -1414,9 +1414,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1427,7 +1427,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -1438,9 +1438,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1892,9 +1892,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2021,14 +2021,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -2047,7 +2047,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -2260,9 +2260,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2493,9 +2493,9 @@ "license": "MIT" }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/loupe": { @@ -2766,9 +2766,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/pathe": { @@ -2807,9 +2807,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -2876,9 +2876,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -3107,14 +3107,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -3126,13 +3126,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -4160,20 +4160,20 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -4203,8 +4203,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, @@ -4394,9 +4394,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4430,10 +4430,7 @@ }, "packages/bridge": { "name": "@ignis/bridge", - "version": "0.0.0-internal", - "devDependencies": { - "esbuild": "^0.20.0" - } + "version": "0.0.0-internal" }, "packages/bridge-plugin": { "name": "@ignis/bridge-plugin", diff --git a/package.json b/package.json index ffdc165..cdce2ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ignis-monorepo", - "version": "0.8.5", + "version": "0.8.6", "private": true, "description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.", "workspaces": [ @@ -11,6 +11,7 @@ "build": "node build.js", "dev:server": "node apps/ignis-server/server/index.js", "dev": "npm run build && npm run dev:server", + "docker:build": "node apps/ignis-server/scripts/build-image.js", "test": "vitest run", "test:watch": "vitest" }, 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/promises.js b/packages/shim/src/fs/promises.js index 7b059aa..391ed3a 100644 --- a/packages/shim/src/fs/promises.js +++ b/packages/shim/src/fs/promises.js @@ -6,6 +6,7 @@ import { resolvePath, } from "./transforms.js"; import { hasVirtualFile, getVirtualFile } from "./virtual-files.js"; +import { realpathSync } from "./realpath.js"; export function createFsPromises(metadataCache, contentCache, transport) { return { @@ -260,7 +261,8 @@ export function createFsPromises(metadataCache, contentCache, transport) { return "/"; } - return transport.realpath(path); + // No symlinks in the vault FS, so realpath is the identity. + return realpathSync(path); }, async utimes(path, atime, mtime) { diff --git a/packages/shim/src/fs/realpath.test.js b/packages/shim/src/fs/realpath.test.js index bc3c9eb..a5cbed3 100644 --- a/packages/shim/src/fs/realpath.test.js +++ b/packages/shim/src/fs/realpath.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { realpath } from "./realpath.js"; +import { createFsPromises } from "./promises.js"; describe("fs realpath shim", () => { it("realpath invokes the callback with the path", async () => { @@ -18,3 +19,18 @@ describe("fs realpath shim", () => { expect(result).toBe("/a/b.md"); }); }); + +describe("fs.promises realpath", () => { + it("answers locally without touching the transport", async () => { + const fs = createFsPromises({}, {}, {}); + + expect(await fs.realpath("/a/b.md")).toBe("/a/b.md"); + }); + + it("maps empty and root paths to /", async () => { + const fs = createFsPromises({}, {}, {}); + + expect(await fs.realpath("")).toBe("/"); + expect(await fs.realpath(".")).toBe("/"); + }); +}); 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/fs/transport.js b/packages/shim/src/fs/transport.js index 1298720..1975d9e 100644 --- a/packages/shim/src/fs/transport.js +++ b/packages/shim/src/fs/transport.js @@ -177,13 +177,6 @@ export const transport = { return requestJson("GET", "/access", { path: normPath(path) }); }, - async realpath(path) { - const result = await requestJson("GET", "/realpath", { - path: normPath(path), - }); - return result.path; - }, - async utimes(path, atime, mtime) { return requestJson("POST", "/utimes", { path: normPath(path), 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); + }); +});