Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -1874,7 +1874,6 @@ Request body:
| `key` | string | no | Remote S3 snapshot.db key |
| `path` | string | no | Absolute path to an encrypted backup package file accessible by the daemon |
| `password` | string | no | Password used to decrypt the encrypted package |
| `allow_unsafe_no_checksum` | boolean | no | Allow restoring a backup with no checksum file |

Responses:

Expand Down Expand Up @@ -3402,7 +3401,7 @@ Resolved category and tag remapping decisions applied to an import preview or co

| Field | Type | Required | Description |
|---|---|---:|---|
| `classification` | "new" \| "active_duplicate" \| "archived_duplicate" \| "trashed_duplicate" \| "invalid_url" \| "private_url" | yes | Import row classification |
| `classification` | "new" \| "active_duplicate" \| "archived_duplicate" \| "trashed_duplicate" \| "invalid_url" \| "private_url" \| "credential_url" | yes | Import row classification |
| `action` | "create" \| "skip" \| "merge" \| "restore_merge" | yes | Action that the selected policy would apply |
| `url` | string \| null | yes | Source bookmark URL |
| `title` | string | yes | Source bookmark title |
Expand Down Expand Up @@ -3505,7 +3504,7 @@ Final committed import row result
|---|---|---:|---|
| `status` | "created" \| "merged" \| "restored" \| "skipped" \| "failed" | yes | Final committed row status |
| `action` | "create" \| "skip" \| "merge" \| "restore_merge" | yes | Requested action selected by the duplicate policy |
| `classification` | "new" \| "active_duplicate" \| "archived_duplicate" \| "trashed_duplicate" \| "invalid_url" \| "private_url" | yes | Import row classification |
| `classification` | "new" \| "active_duplicate" \| "archived_duplicate" \| "trashed_duplicate" \| "invalid_url" \| "private_url" \| "credential_url" | yes | Import row classification |
| `url` | string \| null | yes | Source bookmark URL |
| `title` | string | yes | Source bookmark title |
| `notes` | string \| null | yes | Source note text when the import format provides note-like metadata |
Expand Down Expand Up @@ -4032,7 +4031,6 @@ Response data
| `key` | string | no | Remote S3 snapshot.db key |
| `path` | string | no | Absolute path to an encrypted backup package file accessible by the daemon |
| `password` | string | no | Password used to decrypt the encrypted package |
| `allow_unsafe_no_checksum` | boolean | no | Allow restoring a backup with no checksum file |

### RestoreResult

Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ ENV XDG_CONFIG_HOME=/data/config
ENV HOME=/data
ENV NODE_ENV=production
ENV LOG_FORMAT=json
ENV LITTLEIMP_IN_CONTAINER=1

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
Expand Down
5 changes: 2 additions & 3 deletions daemon/src/api/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@ const schemas = {
ImportPreviewRow: objectSchema(
{
classification: stringSchema("Import row classification", {
enum: ["new", "active_duplicate", "archived_duplicate", "trashed_duplicate", "invalid_url", "private_url"],
enum: ["new", "active_duplicate", "archived_duplicate", "trashed_duplicate", "invalid_url", "private_url", "credential_url"],
}),
action: stringSchema("Action that the selected policy would apply", {
enum: ["create", "skip", "merge", "restore_merge"],
Expand Down Expand Up @@ -816,7 +816,7 @@ const schemas = {
enum: ["create", "skip", "merge", "restore_merge"],
}),
classification: stringSchema("Import row classification", {
enum: ["new", "active_duplicate", "archived_duplicate", "trashed_duplicate", "invalid_url", "private_url"],
enum: ["new", "active_duplicate", "archived_duplicate", "trashed_duplicate", "invalid_url", "private_url", "credential_url"],
}),
url: nullable(stringSchema("Source bookmark URL")),
title: stringSchema("Source bookmark title"),
Expand Down Expand Up @@ -1267,7 +1267,6 @@ const schemas = {
key: stringSchema("Remote S3 snapshot.db key"),
path: stringSchema("Absolute path to an encrypted backup package file accessible by the daemon"),
password: stringSchema("Password used to decrypt the encrypted package"),
allow_unsafe_no_checksum: booleanSchema("Allow restoring a backup with no checksum file"),
}),
RestoreResult: objectSchema(
{
Expand Down
26 changes: 18 additions & 8 deletions daemon/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ function printUpgradeResult(io: Required<Pick<CliIO, "stdout">>, result: Package
io.stdout(`Upgrade complete: ${result.current_version} -> ${result.upgraded_version}`);
io.stdout(`Archive: ${result.archive}`);
io.stdout("Checksum: verified");
io.stdout(result.signature_verified ? "Signature: verified" : "Signature: not provided; checksum verified only.");
io.stdout(result.signature_verified ? "Signature: verified" : "Signature: not verified (unsigned install allowed).");
io.stdout(`Restart: daemon healthy (${result.health_version})`);
io.stdout("Rollback guidance:");
for (const line of result.rollback_guidance) {
Expand All @@ -371,8 +371,8 @@ function usage(): string {
"",
"Usage:",
" littleimp update check [--channel stable|beta] [--source URL] [--json]",
" littleimp update install [--version VERSION] [--release-base-url URL] [--channel stable|beta] [--source URL] [--json]",
" littleimp update install --archive FILE --checksum FILE [--signature FILE] [--json]",
" littleimp update install [--version VERSION] [--release-base-url URL] [--channel stable|beta] [--source URL] [--json] [--allow-unsigned]",
" littleimp update install --archive FILE --checksum FILE [--signature FILE] [--json] [--allow-unsigned]",
" littleimp backup create [--json] [--daemon-url URL]",
" littleimp backup create --encrypt --output FILE [--json] [--daemon-url URL] [--password-file FILE]",
" littleimp backup list [--include-remote] [--json] [--daemon-url URL]",
Expand Down Expand Up @@ -427,7 +427,7 @@ async function handleUpdateInstallCommand(
io: CliRuntime
): Promise<number> {
const parsed = parseArgs(rest, {
booleanFlags: ["--json"],
booleanFlags: ["--json", "--allow-unsigned"],
valueFlags: [
"--archive",
"--checksum",
Expand All @@ -446,6 +446,9 @@ async function handleUpdateInstallCommand(
const signature = parsed.values.get("--signature");
const versionFlag = parsed.values.get("--version");
const json = parsed.flags.has("--json");
const allowUnsigned =
parsed.flags.has("--allow-unsigned") ||
["1", "true", "yes"].includes((io.env.LITTLEIMP_ALLOW_UNSIGNED_UPGRADE ?? "").trim().toLowerCase());
const daemonUrl = getDaemonUrl(parsed, io.env);
const currentVersion = APP_VERSION;
const workDir = createUpgradeWorkDir();
Expand All @@ -464,29 +467,34 @@ async function handleUpdateInstallCommand(
}

try {
const installEnv = allowUnsigned
? { ...io.env, LITTLEIMP_ALLOW_UNSIGNED_UPGRADE: "1" }
: io.env;

const artifact = archive
? {
archivePath: archive,
checksumPath: checksum!,
signaturePath: signature,
}
: await resolveDownloadedUpgradeArtifact(parsed, io, workDir);
: await resolveDownloadedUpgradeArtifact(parsed, io, workDir, { requireSignature: !allowUnsigned });

const verified = verifyAndExtractUpgradeArtifact({
archivePath: artifact.archivePath,
checksumPath: artifact.checksumPath,
signaturePath: artifact.signaturePath,
signatureRunner: io.spawnSync,
env: io.env,
env: installEnv,
workDir,
requireSignature: !allowUnsigned,
});
const guidance = rollbackGuidance(currentVersion, verified.version);

try {
runNativeUpgradeInstaller({
extractedRoot: verified.extractedRoot,
runner: io.spawnSync,
env: io.env,
env: installEnv,
});
} catch (err) {
if (err instanceof UpgradeError) {
Expand Down Expand Up @@ -518,7 +526,8 @@ async function handleUpdateInstallCommand(
async function resolveDownloadedUpgradeArtifact(
parsed: ParsedArgs,
io: CliRuntime,
workDir: string
workDir: string,
options: { requireSignature?: boolean } = {}
): Promise<{
archivePath: string;
checksumPath: string;
Expand Down Expand Up @@ -549,6 +558,7 @@ async function resolveDownloadedUpgradeArtifact(
releaseBaseUrl,
workDir,
fetchImpl: io.fetch,
requireSignature: options.requireSignature !== false,
});
}

Expand Down
27 changes: 15 additions & 12 deletions daemon/src/import/netscape-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* </DL>
*/

import { isPrivateHost } from "../lib/network.js";
import { parsePublicHttpUrl } from "../lib/public-url.js";

export interface ParsedBookmark {
/** Source bookmark row order among <A> tags. */
Expand All @@ -34,7 +34,8 @@ export type ParsedSkippedBookmarkReason =
| "missing_href"
| "invalid_url"
| "non_http_url"
| "private_url";
| "private_url"
| "credential_url";

export interface ParsedSkippedBookmark {
/** Source bookmark row order among <A> tags. */
Expand Down Expand Up @@ -226,25 +227,27 @@ export function parseNetscapeBookmarks(html: string): ParseResult {
break;
}

// Validate URL: must be http/https and must not point to a private/loopback host
// Validate URL: must be http/https without credentials or private hosts
let valid = false;
let skipReason: ParsedSkippedBookmarkReason | null = null;
let skipMessage = "";
try {
const u = new URL(href);
if (u.protocol !== "http:" && u.protocol !== "https:") {
const parsed = parsePublicHttpUrl(href);
if (!parsed.ok) {
if (parsed.reason === "protocol") {
skipReason = "non_http_url";
skipMessage = `Skipped non-http(s) URL: ${href.slice(0, 80)}`;
} else if (isPrivateHost(u.hostname)) {
// Reject private/loopback URLs to prevent SSRF via batch import
} else if (parsed.reason === "private") {
skipReason = "private_url";
skipMessage = `Skipped private/internal URL: ${href.slice(0, 80)}`;
} else if (parsed.reason === "credentials") {
skipReason = "credential_url";
skipMessage = `Skipped URL with embedded credentials near position ${tok.pos}`;
} else {
valid = true;
skipReason = "invalid_url";
skipMessage = `Skipped malformed URL near position ${tok.pos}`;
}
} catch {
skipReason = "invalid_url";
skipMessage = `Skipped malformed URL near position ${tok.pos}`;
} else {
valid = true;
}

if (!valid) {
Expand Down
18 changes: 16 additions & 2 deletions daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { join } from "path";
import { createBackupSnapshot, applyRetentionPolicy } from "./routes/backup.js";
import { settingsManager } from "./settings.js";
import { cronToIntervalMs } from "./lib/cron.js";
import { BindHostError, resolveBindHost } from "./lib/bind-host.js";

const startTime = new Date();

Expand Down Expand Up @@ -86,14 +87,27 @@ const app = createApp({ db, queue, startTime, version: VERSION });
worker.start();
scheduler.start();

let bindHost: string;
try {
const resolved = resolveBindHost(Config.HOST);
bindHost = resolved.host;
if (resolved.warning) {
log.warn(resolved.warning, { host: resolved.host });
}
} catch (err) {
const message = err instanceof BindHostError ? err.message : String(err);
log.error(message);
process.exit(1);
}

const server = Bun.serve({
fetch: app.fetch,
port: Config.PORT,
hostname: Config.HOST,
hostname: bindHost,
});

log.info("littleimpd started", {
host: Config.HOST,
host: bindHost,
port: Config.PORT,
dataDir: Config.DATA_DIR,
version: VERSION,
Expand Down
89 changes: 89 additions & 0 deletions daemon/src/lib/bind-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Bind-address policy for the local-first daemon.
*
* Native defaults remain loopback-only. Wildcard / non-loopback binds require an
* explicit opt-in (or a known container layout) and always emit a warning.
*/

import { existsSync } from "fs";

Check warning on line 8 in daemon/src/lib/bind-host.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=goniszewski_grimoire&issues=AZ-K3tGq13EHziOTFR1I&open=AZ-K3tGq13EHziOTFR1I&pullRequest=201
import { isPrivateHost } from "./network.js";

export class BindHostError extends Error {
constructor(message: string) {
super(message);
this.name = "BindHostError";
}
}

function isLoopbackHostname(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "::1";
}

function isWildcardHostname(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, "");
return host === "0.0.0.0" || host === "::" || host === "*";
}

function envFlagEnabled(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}

export function isContainerBindContext(env: NodeJS.ProcessEnv = process.env): boolean {
if (envFlagEnabled(env.LITTLEIMP_IN_CONTAINER)) return true;
if (env.DATA_DIR === "/data") return true;
// Only probe /.dockerenv when consulting the live process environment so unit
// tests can pass an isolated env object without inheriting the host container.
if (env === process.env) {
try {
return existsSync("/.dockerenv");
} catch {
return false;
}
}
return false;
}

export function allowNonLoopbackBind(env: NodeJS.ProcessEnv = process.env): boolean {
return (
envFlagEnabled(env.LITTLEIMP_ALLOW_NON_LOOPBACK_BIND) ||
envFlagEnabled(env.ALLOW_NON_LOOPBACK_BIND) ||
isContainerBindContext(env)
);
}

/**
* Resolve the hostname passed to Bun.serve.
* Throws BindHostError when the bind is not permitted.
*/
export function resolveBindHost(
rawHost: string,
env: NodeJS.ProcessEnv = process.env
): { host: string; warning: string | null } {
const host = rawHost.trim() || "127.0.0.1";

if (isLoopbackHostname(host)) {
return { host, warning: null };
}

const allowed = allowNonLoopbackBind(env);
if (!allowed) {
throw new BindHostError(
`Refusing to bind HOST=${host}. Loopback (127.0.0.1 / ::1) is required unless ` +
`LITTLEIMP_ALLOW_NON_LOOPBACK_BIND=1 is set, or the process is running in a container. ` +
`Public-network exposure is not a supported Grimoire mode.`
);
}

const isWildcard = isWildcardHostname(host);
const looksPrivate = isPrivateHost(host) || isWildcard;
const warning = isWildcard
? `HOST=${host} binds all interfaces inside this process. Safe only when the host/port publish is loopback-restricted (e.g. docker -p 127.0.0.1:3210:3210).`
: looksPrivate
? `HOST=${host} is a non-loopback bind. Ensure firewalling and that only trusted local clients can reach the daemon.`
: `HOST=${host} is a non-loopback bind. Public exposure of tokenless REST routes is unsupported and dangerous.`;

Check warning on line 86 in daemon/src/lib/bind-host.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=goniszewski_grimoire&issues=AZ-K3tGq13EHziOTFR1J&open=AZ-K3tGq13EHziOTFR1J&pullRequest=201

return { host, warning };
}
Loading
Loading