Skip to content

fix(security): harden SSRF, upgrades, bind policy, and mutation UX - #201

Merged
goniszewski merged 4 commits into
developfrom
cursor/security-hardening-review-fixes-75ce
Aug 12, 2026
Merged

fix(security): harden SSRF, upgrades, bind policy, and mutation UX#201
goniszewski merged 4 commits into
developfrom
cursor/security-hardening-review-fixes-75ce

Conversation

@goniszewski

@goniszewski goniszewski commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the comprehensive code-review hardening items for Grimoire’s critical paths while staying inside the local-first / loopback threat model.

Daemon / security

  • Manual redirect validation for page fetch and update checks (safe-fetch) so private redirect targets are never contacted
  • GET /updates/check rejects arbitrary source query params (default/env source only)
  • Remote upgrades require HTTPS release bases, detached signatures, optional fingerprint pinning via LITTLEIMP_UPGRADE_SIGNING_KEY_FINGERPRINTS, and download size caps; --allow-unsigned / LITTLEIMP_ALLOW_UNSIGNED_UPGRADE=1 is an explicit escape hatch
  • Bind-host policy refuses non-loopback binds unless container context or LITTLEIMP_ALLOW_NON_LOOPBACK_BIND=1 is set, with a loud warning
  • Shared public URL validation rejects embedded credentials across bookmarks / capture / MCP / import; logs redact credentials
  • Default JSON body limits on mutating routes
  • HTTP restore no longer accepts allow_unsafe_no_checksum
  • Concurrent same-URL create races return the existing bookmark instead of a raw UNIQUE error

Frontend

  • Mutations await daemon confirmation before success toasts / dialog close
  • Broader cache invalidation on add/delete
  • Bulk category move uses category IDs; list/search prefer category_id over name
  • Safe http(s) checks before window.open / href
  • Route-level ErrorBoundary
  • Bookmarklet embeds token/URL via JSON.stringify
  • testAiConnection / export download / update-check go through api.ts

Docs

  • API contract regenerated; security boundaries / backup design updated
  • Task report: docs/task-reports/2026/07/2026-07-22-security-hardening-review-fixes/

Test plan

  • Focused daemon tests (fetch redirects, public-url, bind-host, updates, CLI upgrade signatures)
  • npm run check (lint, type-check, frontend + daemon tests, docs:api:check, build)
  • npm run test:e2e (25 passed; revalidated the invalid-URL dialog assertion after copy tweak)
Open in Web Open in Cursor 

cursoragent and others added 4 commits July 22, 2026 17:26
Address the comprehensive code-review findings: validate redirects before
following them, reject arbitrary HTTP update sources, require signed remote
upgrades, fail closed on non-loopback binds outside containers, reject URL
credentials consistently, apply default body limits, drop unsafe restore
bypass from HTTP, and make frontend mutations confirm before success UX.

Co-authored-by: Robert Goniszewski <goniszewski@users.noreply.github.com>
Prefer category_id-only list/search params in hook tests, and use a
concrete ApiUpdateCheckResult shape to avoid deep DTO instantiation.

Co-authored-by: Robert Goniszewski <goniszewski@users.noreply.github.com>
Preserve a "valid URL" phrase in the dialog error so existing journey
coverage continues to match the safer http(s)/no-credentials check.

Co-authored-by: Robert Goniszewski <goniszewski@users.noreply.github.com>
- update/service: thread allowPrivateHosts into the real-fetch path so the
  CLI's local test sources (e.g. --source http://127.0.0.1:9999) are not
  blocked by the new per-hop redirect guard; private hosts stay rejected
  by default on the HTTP route
- pipeline/fetcher: send the current Grimoire/<version> user-agent (was the
  stale LittleImp/0.0 string pointing at the old little-imp repo URL)
- tests: cover private-source checkForUpdates behavior and the fetcher UA

Co-authored-by: Robert Goniszewski <robertgoniszewski@outlook.com>
Signed-off-by: Robert Goniszewski <robertgoniszewski@outlook.com>
@sonarqubecloud

Copy link
Copy Markdown

@goniszewski
goniszewski marked this pull request as ready for review August 12, 2026 09:01
Copilot AI lite review requested due to automatic review settings August 12, 2026 09:01
@goniszewski
goniszewski merged commit 873598f into develop Aug 12, 2026
7 checks passed
@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@goniszewski
goniszewski deleted the cursor/security-hardening-review-fixes-75ce branch August 12, 2026 09:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens Grimoire’s local-first security posture across the daemon (SSRF defenses, update/upgrade authenticity, bind policy, URL validation, body limits) while aligning the frontend UX with “await daemon confirmation” mutation behavior and safer external-link handling.

Changes:

  • Daemon: add centralized public URL parsing/redaction, manual redirect validation for outbound fetches, stricter update/upgrade source + signature requirements, and loopback bind enforcement (with container opt-ins).
  • Frontend: route-level error boundary, safer external URL handling (no credential URLs), centralized API calls, and mutation flows that wait for confirmation before success UI.
  • Docs: regenerated API contract/OpenAPI/API.md plus task report updates for the security hardening work.

Reviewed changes

Copilot reviewed 50 out of 51 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/pages/Settings.tsx Uses api.ts helper for AI connection testing
src/pages/Index.tsx Awaits add/delete operations before success toasts; category move uses IDs
src/lib/safe-url.ts Adds frontend helper to validate safe http(s) URLs without credentials
src/lib/bookmarklet.ts Injects bookmarklet values via JSON.stringify to prevent JS breakouts
src/lib/bookmarklet.test.ts Updates tests for bookmarklet escaping behavior
src/lib/bookmark-open.ts Blocks opening unsafe external URLs
src/lib/api.ts Centralizes update-check, AI test, and export download logic
src/hooks/use-update-check.ts Routes update check through api.ts helper
src/hooks/use-bookmarks.ts Makes add/delete async, broadens invalidations, prefers category_id
src/hooks/use-bookmarks.test.ts Updates expectations to avoid sending both category and category_id
src/hooks/use-app-lock.ts Clarifies lock is UI-only, not daemon authentication
src/components/ExportMenu.tsx Uses centralized export download helper
src/components/ErrorBoundary.tsx Adds route-level error boundary component
src/components/BookmarkDetailContent.tsx Sanitizes markdown links + disables unsafe open actions; safer date formatting
src/components/BookmarkCard.tsx Surfaces an error toast when a bookmark URL can’t be opened safely
src/components/AddBookmarkDialog.tsx Validates URLs (http/s, no credentials) and adds submitting state
src/App.tsx Wraps route tree with ErrorBoundary
docs/task-reports/index.html Adds latest task report card for security hardening
docs/task-reports/2026/index.html Updates July summary to include security hardening
docs/task-reports/2026/07/index.html Adds security hardening report card in July index
docs/task-reports/2026/07/2026-07-22-security-hardening-review-fixes/index.html New security hardening task report page
docs/task-reports/2026/07/2026-07-22-security-hardening-review-fixes/assets/01-hardening-map.svg New SVG asset for the task report
docs/security-boundaries.md Updates update-source boundary language to reflect new constraints
docs/openapi.json Adds credential_url classification; removes unsafe restore option
docs/backup-design.md Removes HTTP restore “unsafe no checksum” option from design docs
docs/api-contract.json Adds credential_url classification; removes unsafe restore option
API.md Removes allow_unsafe_no_checksum; documents new import classification
Dockerfile Sets LITTLEIMP_IN_CONTAINER=1 for container bind-policy detection
docker-compose.yml Sets LITTLEIMP_IN_CONTAINER=1 for container bind-policy detection
daemon/src/update/upgrade.ts Enforces https base URL, signature requirements, fingerprint pinning, size caps
daemon/src/update/service.ts Adds credential checks, safer redirect-following for update checks
daemon/src/test/pipeline/fetch.test.ts Adds redirect/credential/user-agent tests for fetch hardening
daemon/src/test/lib/public-url.test.ts New tests for shared public URL parsing + redaction
daemon/src/test/lib/bind-host.test.ts New tests for bind-host policy
daemon/src/test/integration/updates.test.ts Tightens HTTP update-check source behavior; adds allowPrivateHosts test
daemon/src/test/cli-update.test.ts Adds signature-required behavior + allow-unsigned escape hatch coverage
daemon/src/server.ts Applies default JSON body limits for mutating routes
daemon/src/routes/updates.ts Rejects arbitrary source query param on HTTP update check
daemon/src/routes/import.ts Adds credential_url classification and warning mapping
daemon/src/routes/capture.ts Switches capture URL validation to shared public-url helper
daemon/src/routes/bookmarks.ts Switches bookmark URL validation + log redaction; handles create races
daemon/src/routes/backup.ts Removes HTTP restore checksum bypass behavior
daemon/src/pipeline/fetcher.ts Uses safe redirect-following; validates URLs/credentials; updates UA string
daemon/src/mcp/server.ts Uses shared public URL validation for MCP URL inputs
daemon/src/lib/safe-fetch.ts New safe-fetch helper with manual redirect validation
daemon/src/lib/public-url.ts New shared public http(s) URL validation + redaction
daemon/src/lib/bind-host.ts New bind-host policy for loopback-only defaults
daemon/src/index.ts Enforces bind-host policy at daemon start
daemon/src/import/netscape-parser.ts Adds credential URL skipping reason during import parsing
daemon/src/cli.ts Adds --allow-unsigned and improved signature messaging
daemon/src/api/contract.ts Updates schema enums and removes unsafe restore input field

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +10 to +12
export function isRedirectStatus(status: number): boolean {
return status >= 300 && status < 400;
}
Comment on lines 118 to 123
const { url, title } = body as { url: string; title?: unknown };

if (!isValidUrl(url)) {
return problem(c, 422, "Unprocessable Entity", "Invalid URL - must be http or https");
const parsedUrl = parsePublicHttpUrl(url);
if (!parsedUrl.ok) {
return problem(c, 422, "Unprocessable Entity", urlValidationDetail(url));
}
Comment thread src/pages/Index.tsx
Comment on lines +200 to +223
void Promise.all(ids.map((id) => store.deleteBookmark(id)))
.then(() => {
const count = deletedBookmarks.length;
toast({
title: `${count} bookmark${count !== 1 ? "s" : ""} deleted`,
action: (
<ToastAction
altText="Undo delete"
onClick={() => deletedBookmarks.forEach((b) => void store.restoreBookmark(b.id))}
>
Undo
</ToastAction>
),
});
exitSelectionMode();
setBulkDeleteOpen(false);
})
.catch((err) => {
toast({
title: "Could not delete bookmarks",
description: err instanceof Error ? err.message : "Unknown error",
variant: "destructive",
});
});

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df3b87452b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +331 to +335
const combined = `${outputToString(result.stderr)}\n${outputToString(result.stdout)}`;
const found = [...combined.matchAll(/([A-F0-9]{40}|[A-F0-9]{64})/gi)].map((m) =>
m[1].toUpperCase()
);
const matched = found.some((fp) => allowedFingerprints.includes(fp));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse only GPG's authenticated fingerprint status

When LITTLEIMP_UPGRADE_SIGNING_KEY_FINGERPRINTS is configured, this scans all human-readable GPG output for any 40- or 64-digit hex string, including attacker-controlled user IDs printed in a “Good signature” diagnostic. A valid signature from the wrong imported key can therefore satisfy the allowlist if its user ID contains an allowed fingerprint. GnuPG 2.4.4 exposes --status-fd; parse the VALIDSIG fingerprint from that machine-readable output instead.

Useful? React with 👍 / 👎.

Comment on lines +43 to +48
if (c.req.query("source") != null && c.req.query("source") !== "") {
return problem(
c,
422,
"Unprocessable Entity",
"The source query parameter is not accepted on the HTTP update check. Configure LITTLEIMP_UPDATE_SOURCE or use the CLI."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the rejected source parameter from the API contract

Any client generated from the current contract can still send the documented optional source query parameter, but this branch now unconditionally returns 422 for it. daemon/src/api/contract.ts:1792-1795, docs/api-contract.json, and generated API.md still advertise that parameter, so update the contract and regenerate the documentation alongside this route change.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

Comment thread src/pages/Index.tsx
});
toast({ title: `Moved ${selectedIds.size} bookmark${selectedIds.size !== 1 ? "s" : ""} to "${category}"` });
exitSelectionMode();
void Promise.all(ids.map((id) => store.deleteBookmark(id)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve undo after a partially successful bulk deletion

If one selected bookmark deletion fails after other requests have already succeeded—for example, a stale ID returns 404—Promise.all rejects even though the successful requests are not rolled back. The catch path then reports only failure and never exposes the promised Undo action, leaving some bookmarks moved to Trash with no indication of which ones changed. Collect individual results or restore the successful subset before reporting the operation as failed.

Useful? React with 👍 / 👎.

Comment on lines +19 to +21
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "::1";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept the full loopback address range

Native configurations using valid loopback spellings such as HOST=127.0.0.2 or expanded IPv6 HOST=0:0:0:0:0:0:0:1 now fail startup with BindHostError, even though these addresses do not widen daemon exposure. Classify loopback IPs by address range rather than accepting only the two canonical literals.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants