Skip to content

ci: harden release-bot token handling and e2e temp-dir squatting - #1433

Merged
chhoumann merged 5 commits into
masterfrom
chhoumann/sec-dev-tooling-hardening
Jun 28, 2026
Merged

ci: harden release-bot token handling and e2e temp-dir squatting#1433
chhoumann merged 5 commits into
masterfrom
chhoumann/sec-dev-tooling-hardening

Conversation

@chhoumann

@chhoumann chhoumann commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Summary

Hardens two MEDIUM dev/CI hygiene findings from the security scan. Both are defense-in-depth for the build/release toolchain, not user-facing plugin behavior, and both are assessed honestly against QuickAdd's threat model below. No legitimate local/dev functionality is weakened - the live Obsidian e2e harness still launches and quickadd:list returns ok:true, and the release push path is unchanged.

Finding 1 - Release-bot token persisted to disk before untrusted install/build/test (.github/workflows/release.yml)

The gap (real): the Release job mints a GitHub App token - the "QuickAdd Release Bot", a branch-protection bypass actor - and checked out with persist-credentials defaulting to true, writing the App credential into .git/config (http.extraheader). pnpm install --frozen-lockfile (which runs dependency lifecycle scripts), pnpm run build, and pnpm run test then run before semantic-release consumes the token, so a compromised dependency's postinstall could read it off disk. The token was also over-scoped: create-github-app-token was called with no permission-* inputs, so it inherited all of the App's installation permissions. Every other workflow in the repo (ci.yml, codeql.yml, dependency-review.yml) already sets persist-credentials: false - this was the lone gap.

Fix:

  1. persist-credentials: false on the app-token checkout. The token is still passed (checkout fetches as the App), but it is no longer written to .git/config. The push still works: semantic-release authenticates from the GITHUB_TOKEN env on the Release step - it overwrites options.repositoryUrl with a token-embedded x-access-token: URL (semantic-release/index.js:67 + lib/get-git-auth-url.js:69,99-102) and never reads .git/config.
  2. Scope the minted token to permission-contents/issues/pull-requests: write (the complete set semantic-release uses: push commit/tag + create Release via contents; comment on released issues/PRs via issues/pull-requests). Attestation, Find-PR, and Comment-on-PR all use the default GITHUB_TOKEN, not this token.

harden-runner is deliberately left on egress-policy: audit: switching to block needs an allowlist of every endpoint a real release touches (GitHub API, codeload, npm registry, asset-upload host, Sigstore) and silently breaks releases if incomplete - it cannot be validated without a real release run, and the disk-exposure window is already closed by (1).

Finding 2 - Predictable /tmp profile dir holding a symlink to the real keychain (scripts/start-obsidian-e2e-instance.mjs)

The gap (low likelihood, real pattern): the e2e profile root defaults to /tmp/quickadd-obsidian-e2e (world-writable parent) and symlinks the real ~/Library/Keychains into a HOME Obsidian launches with. fs.mkdir(..., {recursive}) is a no-op for ownership/mode on a pre-existing dir, so a co-located actor on a shared/reused host could temp-squat the predictable root before the victim's first run and TOCTOU-hijack the keychain-bearing writes. This is a dev/CI harness on macOS, not the shipped plugin; the common single-user-dev case has no attacker. But the asset (the login keychain) and the recognized insecure-temp pattern justify proportionate hardening.

Fix - ensureSecureDir (fail-closed), not a redesign:

  • lstat first so we never mkdir through a pre-existing symlink; create only when absent; then assert the dir is a real directory we own with no group/other access. A symlink, a foreign owner, or a loose mode is rejected (not chmod-repaired - see review note). A 0o700-owned dir cannot have foreign-planted children, so descending into it is then safe.
  • Applied parent-first (profileRoot before instancePath) in prepareObsidianProfile, before the reaper in both mains, in launchObsidianInstance, and - validate-only via assertSecureDirIfPresent - in the stop/reap teardown paths.
  • The /tmp default and the stable instance-id layout are kept. Moving the root under os.tmpdir() was prototyped and reproduced to break the harness: /var/folders/.../T pushes Obsidian's $HOME/.obsidian-cli.sock past the macOS 104-byte sun_path limit (EINVAL), and vitest wouldn't catch it. The keychain symlink is load-bearing (SecretStorage parity, pinned by tests) and is left intact; the temp-dir guard removes the hijack vector without touching it.

Exploit / mitigation evidence (run live in this worktree's isolated vault)

  • Legitimate path intact: pnpm run obsidian:e2e -- quickadd:list launches Obsidian and returns {"ok":true,"command":"quickadd:list","count":0,"choices":[]}; pnpm run stop:e2e-obsidian terminates the process tree and removes the instance dir.
  • Temp-squat refused, no write-through: a symlinked profile root makes both start and stop --prune abort with Refusing to use <path>: it is a symlink or not a regular directory., and the symlink target stays empty.
  • Release auth unchanged: verified at the installed semantic-release@25.0.5 source that the push URL is built from GITHUB_TOKEN, independent of .git/config.

Regression tests (fail without the fix)

  • tests/release-workflow-hardening.test.ts - asserts persist-credentials: false, the retained token: line, and the three scoped permission-* inputs, anchored to real YAML lines with full-line comments stripped (a commented-out guard cannot satisfy it). RED on master (all four guard strings absent), GREEN here.
  • tests/start-obsidian-e2e-instance.test.ts - ensureSecureDir / assertSecureDirIfPresent: fresh -> 0o700/ours, loose -> rejected, symlink -> rejected (no write-through), foreign uid -> rejected, currentUid:null -> owner-check skipped, absent -> false without creating. The symlink + foreign-uid cases are RED when the guards are removed.

Process

Threat-model judgment and fix design were validated by an adversarial reviewer fleet before coding (it caught and rejected the os.tmpdir() break), and the implemented diff was reviewed by three opposite-model (Codex) reviewers (skeptic/architect/minimalist) whose consensus findings - reject-only over chmod-repair, guard the teardown paths, anchor the YAML test - are folded in.

Gates: tsc -noEmit, eslint ., svelte-check (0 errors), full vitest (3179 passed / 37 skipped) all green.

Summary by CodeRabbit

  • Bug Fixes

    • Hardened the release automation workflow to avoid writing privileged credentials to local Git config and to apply least-privilege permissions for the minted app token.
    • Added stricter validation for Obsidian E2E profile/instance directories, rejecting symlinked or insecurely owned locations and refusing cleanup/start operations when the profile root is not trusted.
  • Tests

    • Added security-focused tests to ensure the release workflow stays hardened and to verify directory security behavior (creation, ownership, permissions, and symlink rejection).

The Release job mints a privileged GitHub App token (a branch-protection
bypass actor) and checked out WITH persist-credentials defaulting to true,
writing the App credential into .git/config. install/build/test then run
before semantic-release consumes it, so a compromised dependency's
postinstall could read the token off disk.

- persist-credentials: false on the app-token checkout. semantic-release
  authenticates its push from the GITHUB_TOKEN env on the Release step
  (it builds an x-access-token URL via getGitAuthUrl; it never reads
  .git/config), so the release push still works. Matches every other
  workflow in the repo (ci/codeql/dependency-review all set this).
- Scope the minted token to permission-contents/issues/pull-requests:write
  instead of inheriting ALL of the App's installation permissions.
- Regression test asserting both guards on release.yml.
…/TOCTOU)

The per-instance Obsidian profile defaults under world-writable /tmp and
symlinks the real macOS keychain into a HOME the instance launches with.
fs.mkdir(...,{recursive}) is a no-op for ownership/mode on a pre-existing
dir, so a co-located actor who pre-creates the predictable profile root (or
symlinks it elsewhere) before the victim's first run could redirect the
keychain-bearing writes.

Add ensureSecureDir: lstat FIRST (never mkdir through a pre-existing
symlink), create only when absent, then assert the dir is a real directory
we own with no group/other access (tightening a loose mode we own, refusing
a foreign owner or a symlink). Validate profileRoot before instancePath
(parent-first) in prepareObsidianProfile and before the reaper scans the
root in both start + the CLI wrapper. The /tmp sticky bit then prevents
swapping our validated 0o700 dir.

Keeps the stable instance-id layout and the /tmp default unchanged (moving
the root under os.tmpdir() was rejected: /var/folders pushes Obsidian's
unix control socket past the 104-byte sun_path limit and breaks the
harness). Regression tests cover fresh/loose/symlink/foreign-uid/no-getuid.
Three Codex reviewers (skeptic/architect/minimalist) converged on real gaps:

- Reject loose (group/other-accessible) pre-existing profile dirs instead
  of chmod-repairing them. An attacker who could write while the dir was
  loose may already have planted a 'home' symlink that a parent chmod would
  not undo (later recursive mkdir / keychain link would follow it). A
  0o700-owned dir cannot have foreign-planted children, so reject-only fully
  closes the race - and drops a branch.
- Guard the teardown paths too: stop/reap scanned and removed under the
  predictable profile root without validation. New assertSecureDirIfPresent
  (validate-only, never creates/chmods) refuses a hijacked/symlinked root in
  stop main() and reapOrphanedInstances.
- Swap launchObsidianInstance's raw recursive mkdir for ensureSecureDir so a
  future relaunch path cannot bypass the guard.
- Anchor the release.yml regression test to real YAML lines and strip
  comments, so a commented-out guard can no longer satisfy it.

Regression tests updated: loose-dir now asserts rejection; added
assertSecureDirIfPresent coverage (absent->false/no-create, symlink->throws,
owned->true). Live: harness still launches + QuickAdd ok; stop --prune
refuses a symlinked root; full teardown still terminates+removes.
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@chhoumann, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 30 minutes and 15 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 720ad54c-8e93-476d-99d3-97f2db8fa353

📥 Commits

Reviewing files that changed from the base of the PR and between 53306b6 and b833688.

📒 Files selected for processing (1)
  • tests/release-workflow-hardening.test.ts
📝 Walkthrough

Walkthrough

The PR hardens the release workflow token setup and adds filesystem security checks around Obsidian E2E profile directories, wiring the checks into startup, stop, and CLI flows. New tests cover both the workflow configuration and the secure-directory helpers.

Changes

Release Workflow Token Hardening

Layer / File(s) Summary
Release workflow: token scoping and credential non-persistence
.github/workflows/release.yml
Adds explicit token permission inputs and sets persist-credentials: false on checkout while keeping full-history fetch.
Release workflow hardening tests
tests/release-workflow-hardening.test.ts
Adds a YAML-parsing test suite that asserts the checkout and permission settings appear in the expected workflow step.

E2E Profile Directory Security Guards

Layer / File(s) Summary
Secure directory helpers and startup guards
scripts/start-obsidian-e2e-instance.mjs
Adds ensureSecureDir and assertSecureDirIfPresent with UID, mode, ownership, and symlink checks, and uses them before profile creation, instance launch, and stale-instance reaping.
Stop script and CLI wiring for secure profileRoot
scripts/stop-obsidian-e2e-instance.mjs, scripts/obsidian-e2e-cli.mjs
Gates orphan reaping and CLI startup on secure profileRoot validation before stop/reap operations run.
Tests for secure directory helpers
tests/start-obsidian-e2e-instance.test.ts
Covers secure directory creation, rejection cases, and assertSecureDirIfPresent behavior for missing, valid, and symlinked roots.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • chhoumann/quickadd#1331: Changes the same release workflow token flow used for checkout and semantic-release.
  • chhoumann/quickadd#1354: Adds the Obsidian E2E CLI entrypoint that this PR updates with secure profile-root validation.
  • chhoumann/quickadd#1368: Touches the Obsidian instance stop/reap flow that is now guarded by secure-directory checks.

Poem

🐇 I sniffed the root and checked the gate,
No sneaky symlink gets to wait.
The checkout token stays tucked away,
With scopes so tight it won’t stray.
My paws approve the secure run—
Hop, hop, the tests have won!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main CI security hardening for release-token handling and e2e temp-dir squatting.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chhoumann/sec-dev-tooling-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chhoumann chhoumann changed the title ci/test: harden release-bot token handling and e2e temp-dir squatting ci: harden release-bot token handling and e2e temp-dir squatting Jun 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 28, 2026

Copy link
Copy Markdown

Deploying quickadd with  Cloudflare Pages  Cloudflare Pages

Latest commit: b833688
Status: ✅  Deploy successful!
Preview URL: https://d0ae5b9e.quickadd.pages.dev
Branch Preview URL: https://chhoumann-sec-dev-tooling-ha.quickadd.pages.dev

View logs

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/release-workflow-hardening.test.ts (1)

56-63: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert the permission list is closed, not just present.

Line 61-63 would still pass if a future edit adds another permission-* scope to the App token step. Since the test says these three are the complete set, assert the exact set.

Proposed test tightening
-		expect(appToken).toMatch(/^\s*permission-contents:\s*write\s*$/m);
-		expect(appToken).toMatch(/^\s*permission-issues:\s*write\s*$/m);
-		expect(appToken).toMatch(/^\s*permission-pull-requests:\s*write\s*$/m);
+		const permissionLines = appToken
+			.split("\n")
+			.map((line) => line.trim())
+			.filter((line) => line.startsWith("permission-"));
+
+		expect(permissionLines.sort()).toEqual(
+			[
+				"permission-contents: write",
+				"permission-issues: write",
+				"permission-pull-requests: write",
+			].sort(),
+		);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/release-workflow-hardening.test.ts` around lines 56 - 63, The App token
permission test is only checking that three permission-* scopes are present, so
it would still pass if extra scopes are added. Tighten the assertion in the
release workflow test around stepContaining("uses:
actions/create-github-app-token@") to verify the exact closed set of
permissions, so only contents, issues, and pull-requests with write access are
allowed and any additional permission scope causes a failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/start-obsidian-e2e-instance.mjs`:
- Around line 305-308: Secure the parent profile root before validating the
instance path in launch-only flows by mirroring the ordering used in
prepareObsidianProfile. In launchObsidianInstance, call ensureSecureDir on
options.profileRoot before the existing ensureSecureDir(options.instancePath)
check so a future relaunch path cannot leave a writable parent around a
validated child. Keep the guard in place for both paths and preserve the
existing temp-squat protection logic.

---

Nitpick comments:
In `@tests/release-workflow-hardening.test.ts`:
- Around line 56-63: The App token permission test is only checking that three
permission-* scopes are present, so it would still pass if extra scopes are
added. Tighten the assertion in the release workflow test around
stepContaining("uses: actions/create-github-app-token@") to verify the exact
closed set of permissions, so only contents, issues, and pull-requests with
write access are allowed and any additional permission scope causes a failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: eb2b5db9-250b-4b37-ab9f-c980be1c84e1

📥 Commits

Reviewing files that changed from the base of the PR and between c529b22 and cd4b696.

📒 Files selected for processing (6)
  • .github/workflows/release.yml
  • scripts/obsidian-e2e-cli.mjs
  • scripts/start-obsidian-e2e-instance.mjs
  • scripts/stop-obsidian-e2e-instance.mjs
  • tests/release-workflow-hardening.test.ts
  • tests/start-obsidian-e2e-instance.test.ts

Comment thread scripts/start-obsidian-e2e-instance.mjs Outdated
…stance

CodeRabbit (and the architect reviewer) noted the launch guard validated
only the instance dir: a future relaunch path calling launchObsidianInstance
without prepareObsidianProfile/main's preflight would leave a loose
profileRoot attacker-writable around the validated child. Mirror the
parent-first ordering used in prepareObsidianProfile so launch is
self-sufficient.
CodeRabbit nit: the scope test only checked the three expected permission-*
inputs were present, so a future over-broad addition (e.g.
permission-administration: write) would slip through. Parse every
permission-* input on the create-github-app-token step and assert the set
equals exactly {contents, issues, pull-requests}: write - nothing broader.
Verified: an injected extra permission now fails the test.
@chhoumann
chhoumann merged commit ca45a35 into master Jun 28, 2026
10 checks passed
@chhoumann
chhoumann deleted the chhoumann/sec-dev-tooling-hardening branch June 28, 2026 20:40
@quickadd-release-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.14.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant