Merge pull request #32 from Nystik-gh/v0.8.6

0.8.6: proxy rewrite, offline deploy method, realpath fix, minor fixes
This commit is contained in:
Nystik
2026-06-13 15:47:05 +02:00
committed by GitHub
24 changed files with 791 additions and 198 deletions

View File

@@ -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

View File

@@ -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/<module>.js`
1. Create the shim in `packages/shim/src/node/<module>.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

View File

@@ -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.

View File

@@ -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 <image>: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);

View File

@@ -30,7 +30,48 @@ 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
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
echo "[ignis] First run. Unpacking local Obsidian package: $OBSIDIAN_PACKAGE"
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}..."
curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian-${OBSIDIAN_VERSION}.asar.gz" \
@@ -41,8 +82,14 @@ if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR"
rm -f /tmp/obsidian.asar
fi
echo "[ignis] Obsidian v${OBSIDIAN_VERSION} ready."
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

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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" });

View File

@@ -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,
});

View File

@@ -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 });
}
});

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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 });
}
});

View File

@@ -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;
}

171
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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}'`,
);

View File

@@ -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) {

View File

@@ -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("/");
});
});

View File

@@ -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");

View File

@@ -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),

View File

@@ -2,7 +2,7 @@
function isSameOrigin(url) {
if (
!url ||
url.startsWith("/") ||
(url.startsWith("/") && !url.startsWith("//")) ||
url.startsWith("./") ||
url.startsWith("../")
) {

View File

@@ -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);
});
});