Commit Graph

8 Commits

Author SHA1 Message Date
Anso d5ef403f67 feat(git): per-source private CA bundles and redirect credential guard (#1870)
* feat(git): add per-source private CA bundles and redirect credential guard

Let operators trust self-hosted HTTPS git servers by storing an encrypted
per-source CA PEM that is combined with system anchors at fetch time, and
block smart-HTTP redirects plus credential helper host scoping so PATs
cannot follow a cross-host Location header.

* fix(git): support removing a stored custom CA bundle

The custom CA bundle field in the Git source edit panel could be
replaced but not removed. The textarea starts empty after load, and
the save body omitted ca_bundle whenever the field was empty, which
the backend interpreted as "keep existing." An operator who retired
or no longer trusted a private CA had no way to revoke the stored
trust anchor.

Add an explicit remove_ca_bundle: true flag the UI sends alongside
the empty ca_bundle when the operator clicks "Remove stored CA."
The backend treats the flag as a clear, even when the field is
omitted, so saved revisions can revoke trust. Round-trip tests at
the service and route layers store, revoke, reload, and confirm
has_ca_bundle is false and the encrypted column is null.

In the same change, address three follow-on gaps in the same surface:

* Extract the per-fetch PEM-file write to
  backend/src/services/git/gitCaBundleSink.ts and add the file to
  paths-ignore in .github/codeql/codeql-config.yml with a comment
  explaining the trust boundary. The sink validates every PEM it
  writes and refuses non-PEM material; the path is always under the
  caller's per-fetch workspace.
* Add e2e/git-source-ca.spec.ts, which drives the full chain
  (API PUT with ca_bundle, API GET, real HTTPS pull, API PUT with
  remove_ca_bundle, API GET) against a local TLS fixture server.
* Drop http.followRedirects=false for HTTPS. Cross-host credential
  safety is already enforced by the host-scoped credential helper,
  which refuses to emit credentials to a host that does not match
  the configured repository. Same-host redirects now continue to
  work, and a new live integration test proves a cross-host
  redirect receives no credentials and the fetch fails closed
  (the redirected host records no Authorization header).

Extract the buildBareRepo helper into a shared test fixture so the
two git integration tests no longer duplicate the bootstrap.

* chore(git): clean up test surfaces on the private-CA branch

Two small follow-ups on the per-source custom CA bundle work:

* Drop the unused Page import in e2e/git-source-ca.spec.ts that the
  code-quality review surfaced. The test body never referenced the
  type, so the import is dead weight.
* Tighten the file header in
  backend/src/__tests__/git-redirect.integration.test.ts so it
  describes what the test pins (cross-host credential refusal, with
  same-host redirects preserved) instead of how it came to be
  written. No behavior change; the assertion set is unchanged.

* fix(git): restore additive platform CA trust and redirect-scope validation

* fix(git): redirect protection, CA bundle fixtures, docs accuracy

* fix(git): redirect enforcement, fixtures, docs, E2E, packet

* fix(git): validate redirect destinations before contacting them

Git ran with http.followRedirects=false and the code that was meant to
recover legitimate redirects keyed off a `Location:` header in git's
stderr. git-remote-http never prints one: it reports only
"The requested URL returned error: 302" when following is disabled, and
prints the destination only on the path where it has already followed
the redirect. The parser therefore never matched, the same-host retry
never fired, and the policy collapsed into deny-all, so every same-host
redirect failed with exit 128 across resolve, fetch, and fast-forward
verification. The retry itself was also malformed: it dropped the config
value while leaving its preceding `-c`.

Redirect policy now lives in redirectPreflight.ts. When git refuses a
redirect, the chain is walked here with an unauthenticated request and
every hop is validated before it is followed: HTTPS only, no loopback,
RFC 1918 or link-local destination, and no host outside the credential
scope. Only an approved chain yields a URL git is re-run against, and it
is applied consistently to resolveRef, fetchAtCommit and
verifyFastForward. A rejected destination is never contacted at all,
which is what keeps the internal-range guard preventive rather than
after the fact.

* test(git): prove redirect policy and per-source CA trust from observed behaviour

The redirect tests asserted only that a fetch rejected, which any failure
satisfied, including one where git never reached the fixture at all. They
are now a matrix over the cases that actually differ: a same-host
redirect resolves the ref both anonymously and with a token, a wrong
token behind that redirect still reports an authentication failure rather
than a redirect failure, and a cross-host redirect is refused against a
destination proven in the same run to serve the ref. Each fixture records
the requests it received, so "never contacted" and "never offered the
token" are read off the server rather than inferred. A probe detects
environments where a spawned git cannot reach loopback and skips there
instead of passing without asserting anything.

The per-source CA E2E ran against a fixture whose certificate the backend
also trusted process-wide, so it passed whether or not the stored bundle
ever reached git, and its closing assertion accepted 200, 500 or 404. The
fixture now presents a certificate from a separate CA that nothing else
trusts, which makes the stored bundle the only thing that can authorise
the fetch, and removing it is required to produce the classified TLS
trust failure.

* fix(git): report why a redirect preflight declined instead of failing quietly

Review of the redirect work found two fail-closed paths that were correct
but undiagnosable. A probe that could not complete was swallowed by a bare
catch, so a private CA that fails to validate looked exactly like a server
that does not redirect. A CA bundle that could not be read fell back to
default trust, which would then validate the operator's private-CA host
against the wrong anchors and fail for a reason nothing reported.

Both now say what happened. An unreadable bundle also stops authorising a
retry rather than probing with trust the operator did not configure, since
that file was written moments earlier by the same invocation and failing
to read it back is a fault rather than a missing option.

Also pins the stderr wording the redirect detector matches, so a git
upgrade that rephrases it fails a test instead of quietly making relocated
repositories unreachable, covers the absolute-Location branch of the
chain walker, and makes the real-git matrix a hard failure in CI when git
cannot reach a loopback fixture. Skipping is right on a workstation that
cannot do this, but in CI it would retire the whole matrix and leave a
green run with nothing exercised.

Documents the redirect behaviour operators can now rely on: a relocation
that stays on the same server keeps working, and one that points
elsewhere is refused without that server being contacted.

* fix(git): run the redirect matrix instead of skipping it, and sanitize its logs

The reachability probe added with the matrix used spawnSync, which blocks
the event loop, so the in-process TLS fixture could never answer it. The
probe timed out and concluded git could not reach loopback, which was
wrong: the cases themselves drive git through the non-blocking spawn path
and work fine. Locally that silently skipped all five, and in CI the guard
turned the mistake into a failure. Removed, so the matrix runs everywhere:
all five now execute in well under a second each.

The two warnings added for declined preflights interpolated a host and an
error message straight into the log line. Both now go through the
sanitizer the repository already registers as a log-injection barrier.

The preflight's outbound request is reported as request forgery because
the URL derives from the configured repository. The first request goes to
that same URL git fetches from anyway, and every later hop is checked
against its origin before being requested, so the walk cannot reach a host
the operator did not configure. Recorded as a scoped exclusion for that one
query, alongside the existing entries that settle the same trust model, so
every other query still analyzes this file.

* fix(git): route every preflight request through one origin check

The redirect preflight necessarily sends the operator's configured
repository URL to an outbound request, which reads as request forgery. The
guarantee the module provides is narrower than the URL being trusted:
nothing is requested that has not first been checked against the
configured origin. That was true of the loop but only as a property of its
shape, so it is now a single function every URL passes through, the seed
included, leaving no path to the network that skips the check.

Declaring that function a barrier states the property to the analysis
instead of excluding the file, so every other query keeps analyzing the
one module whose job is preventing this class of bug. Same mechanism the
repository already uses for log sanitization.

Also sanitizes the kill-confirmation log line, which interpolates a
repository host label supplied by configuration.

* fix(git): fall back to excluding the redirect preflight from CodeQL JS analysis

The barrier model on approvedUrl did not clear the request-forgery alert:
js/request-forgery does not consult the general dataflow barrierModel the
way js/log-injection does, so declaring the origin check's return value
clean had no effect on this query. Falling back to the paths-ignore
mechanism already proven for the two credential sink modules, with the
same trust-model rationale recorded inline: every URL requested, the seed
included, is checked against the configured repository's origin first,
so the walk cannot reach a host the operator did not configure.

The origin-check refactor itself stays; it is a real improvement (one
inspectable choke point instead of a property of the loop's shape) whether
or not the analysis can see it.

* fix(git): allow explicit CA removal to save even when the server currently needs it

Every save runs a dry-run reachability fetch before persisting, including
a revocation. Resolving the stored CA bundle for that fetch already
returns null once removeCaBundle is set, so removing a CA that the
server actually needs to be reached makes the dry-run fail on certificate
trust, and the removal itself gets refused with the same TLS error the
operator was trying to get past. Retiring a certificate that is expiring,
rotated, or no longer trusted was blocked by exactly the unreachability
that retiring it causes.

The dry-run now runs only when a CA bundle is not being explicitly
removed. Every other save path (add or replace a CA, change the
repository or branch) keeps the check unchanged; only remove_ca_bundle
skips it, and only for that one field. Removal always persists, and the
next pull reports the real reachability state.

This surfaced from the E2E hardening in the previous commit: isolating
the CA fixture so the stored bundle is actually load-bearing exposed a
save-time check that the old, globally-trusted fixture had always masked.

* fix(git-source): classify IP-SAN TLS mismatches, fix redirect probe URL, show CA-removal armed state

Live fleet QA against this branch surfaced three defects introduced by
this PR:

- classifyGitFailure's hostname-mismatch regex missed curl's actual
  wording for an IP-address SAN mismatch, so the raw stderr leaked
  through instead of the classified TLS message.
- resolveRedirectedRepoUrl built its initial ref-advertise probe URL by
  string concatenation, corrupting the URL when the source repo URL
  already carried a query string.
- Clicking "Remove stored CA" armed a revocation flag with no visible
  feedback, so an operator could not tell whether the click registered
  or whether typing in the textarea had silently un-armed it.

Adds regression tests for all three.
2026-09-01 09:33:27 -04:00
Anso 3ca0f8e5d4 feat(git): SSH deploy keys with strict host-key verification (#1867)
* feat(git): add SSH deploy keys with strict host-key verification

Enable private Git repositories over SSH using encrypted deploy keys and
ssh-keyscan-backed host trust, with UI probe flow and integration coverage.

* refactor(git): drop the unused token decrypt from the pull path

resolveTransportAuth already resolves the credential for the selected auth
type, so the earlier decrypt fed nothing and needlessly decrypted a secret on
every pull. It also hard-failed a deploy-key source that carried a stale token
row, naming a credential the source does not use.

* test(git): stabilize the Git source panel load test and report sshd startup stderr

The panel test used the footer Save button as its load barrier, but that button
renders during loading too, so the assertions ran against the loading skeleton
and failed on slower runners. Wait on the repository URL field instead, which
only appears once the load settles.

The SSH fixture collected sshd's stderr but never read it, leaving an opaque
port timeout as the only signal when the server fails to start.

* fix(git): close pre-merge audit gaps for SSH deploy keys

Persist deploy-key credentials in create checkpoints and restore them on
recovery, forward scoped stack evidence for remote host-key probes, derive
SSH trust fingerprints server-side with audit events, and add regression
coverage for recovery, proxy auth, integration ports, and the UI probe flow.

* test(git): scope the host-key fingerprint assertion to the inline element

The probe test asserted the fingerprint with a substring locator, which
matched both the success toast (which echoes the value) and the inline
fingerprint element, tripping Playwright strict mode. Match exactly so the
assertion targets the panel's rendered value rather than the transient toast.

* fix(git): close audit round-2 gaps for SSH deploy keys

Mandatory default-port integration coverage, real SSH browser E2E,
proxied trust-audit actor attribution, refreshed operator screenshots,
and CI steps to free loopback port 22 for SSH fixture tests.

* ci: harden loopback port 22 teardown for SSH fixture tests

Mask and stop ssh socket units, kill listeners, and verify bind before
backend integration and E2E jobs run default-port SSH coverage.

* ci: verify port 22 with listener checks and grant sshd bind cap

Avoid unprivileged bind probes on privileged ports and let the SSH
fixture listen on loopback :22 in CI after teardown.

* test(git): cover SSH trust rotation audit and key preservation

* fix(git): surface SSH host-key rotation and align URL validation

Phase E fixes for PR #1867: warn when host-key fingerprint changes on re-probe,
accept non-git SSH usernames in client URL validation, and show create-from-git
errors inline instead of overlapping toasts.

* fix(security): canonicalize SSH credential files before write

Address CodeQL js/http-to-file-access on sshTrust write paths by rebuilding
deploy keys and known_hosts from validated structure only, with query filter
and MaD barriers.

* fix(security): exclude SSH credential sink module from CodeQL analysis

Move writeDeployKey/writeKnownHosts to sshCredentialFiles.ts and paths-ignore it.
query-filters path excludes do not apply to js/http-to-file-access.
2026-08-29 16:52:32 -04:00
Anso 0ba09ebdee feat: add ntfy notification channel (#1761)
* chore: bump brace-expansion and fast-uri via npm audit fix

Resolves GHSA-rgw5-rvv9-x895 (brace-expansion DoS via unbounded
intermediate arrays). Both transitive dev dependencies updated:
- brace-expansion 5.0.8 -> 5.0.9
- fast-uri 3.1.4 -> 3.1.5

* chore: also bump frontend deps via npm audit fix

Fixes brace-expansion and postcss in the frontend lockfile so
npm audit --audit-level=high passes on both packages.

* chore: bump ip-address transitive dep via npm audit fix

Resolves three new ip-address advisories (GHSA-mwp4-54f8-5fhr,
GHSA-4xrf-jv44-h6hh, GHSA-22jq-vg5j-6vgg) published between prior
push and CI run.

* feat: add ntfy notification channel

Add ntfy (https://ntfy.sh) as the fifth notification channel alongside
Discord, Slack, Webhook, and Apprise. ntfy speaks its native protocol:
plain-text POST body with Content-Type, Title, Priority, and Tags
headers. Priority maps info/warning/error to ntfy's default/high/urgent.

URL validation allows both HTTP and HTTPS (common for LAN self-hosting)
but rejects embedded credentials, consistent with Apprise. Token auth
via ntfy's documented ?auth= query parameter is supported.

* fix: correct ntfy channel test cases for Linux URL parsing and required type field

- notification-channels.test.ts: replace http:///topic host check with a
  cross-platform invalid-URL case (WHATWG parser treats triple-slash
  authority differently on Linux vs Windows)
- ConfigurationStatus.test.tsx: add ntfy agent slot to makePayload and
  inline agents fixtures (required by the expanded ConfigurationAgents
  type)

* fix: remove unused import and update 0/4 masthead assertions to 0/5

* ci: exclude NotificationService.ts from js/request-forgery CodeQL rule

Notification channel dispatch methods (Discord, Slack, Webhook, Apprise,
ntfy) all call fetch() with admin-configured URLs and notification bodies
that may embed stack or path data. This matches the trust model already
documented for registry-api.ts: single-tenant self-hosted, admin owns
the server, outbound posting is the intended behavior. The write path is
gated by requireAdmin or requirePermission(node:manage), and every
dispatch runs with a 10s AbortSignal.timeout.

* ci: also exclude NotificationService.ts from js/file-access-to-http

Notification messages may embed stack names, paths, or compose-derived
content. Same trust model as js/request-forgery: admin owns the server
and the configured endpoints, write path is gated.

* fix: correct ntfy channel tab copy and validation error message

The ntfy settings tab was reusing the generic webhook label, helper, and
placeholder (Webhook URL / JSON payloads / https://...). Give ntfy its own
copy: label names the server-and-topic URL, helper states plain-text delivery
and the mandatory topic path, placeholder matches the routing section.

Also fix the routing-rule validation toast: the guard correctly exempts ntfy
from the HTTPS check but the error message was not updated alongside it, so
ntfy URLs received a misleading HTTPS-required message.

* fix: strip trailing slash from ntfy topic URL before dispatch

A topic URL like https://ntfy.sh/mytopic/ validates fine (the check strips
the trailing slash internally) but was stored and dispatched with the slash
intact, causing the real ntfy server to 404. Normalize before fetch so the
request reaches the correct topic path.

Also add ntfy to the Channels card description in the settings registry.
2026-08-03 19:29:41 -04:00
Anso 66ec4ebdd2 fix(image-updates): treat multi-arch child digests as up to date (#1641)
* fix(image-updates): treat multi-arch child digests as up to date

Floating tags like redis:8-alpine can store a platform child digest locally
while the registry tag resolves to the parent index. Compare against runnable
index members via a digest-pinned expansion so current images stop false-positive
update badges. Fixes #1630.

* fix(image-updates): preserve UTF-8 in capped GET and fail closed on nested indexes

Accumulate raw Buffer chunks before hashing or decoding so multibyte UTF-8
cannot corrupt content digests. Expand nested OCI indexes with depth/visited
caps, match platform-less leaves by exact digest, and return error instead of
update when classification is incomplete.

* fix: prefer-const lint error in registry-api test

* fix(image-updates): align multi-arch checkNode tests with 2-arg signature

After rebasing onto main (#1640), checkNode no longer takes nodeName. The
two persistence tests still passed the node label as db, which broke CI on
the pull_request merge ref.

* fix(image-updates): guard tag/repo components before registry URL construction, dismiss CodeQL false positive

Add defense-in-depth validation in probeManifestForRef that rejects tag
strings containing URL-injection characters (/ ? # \ null) and repo paths
with .. segments before they reach the outbound HTTPS request. These
characters are not valid in Docker tags or OCI distribution spec repo
segments, so no valid image reference is affected.

Exclude js/request-forgery on registry-api.ts via codeql-config.yml.
Sencho is single-tenant and self-hosted: the admin who writes compose
files already has code execution, and specifying arbitrary registries
is by design. The validation guard above prevents actual URL injection;
the remaining taint path is inherent to the image-update feature rather
than an actionable vulnerability.

Closes CodeQL alerts #531 and #532.
2026-07-17 08:47:22 -04:00
Anso a3033a848e ci: scope CodeQL e2e tmpfile suppression via paths-ignore (#1346)
The js/insecure-temporary-file rule fires on e2e Playwright specs that
seed fixtures into the backend's COMPOSE_DIR (a fixed /tmp path) so the
API under test can read them back. A randomized mkdtemp cannot apply
there: the backend resolves paths against its own COMPOSE_DIR, so a
fixture written elsewhere would be invisible to it.

The prior suppression used a paths key inside a query-filters exclude,
which CodeQL ignores: query-filters match on query metadata, not source
path. That left the rule firing on every new e2e spec. Move the
exclusion to a top-level paths-ignore, the only mechanism that scopes
analysis by source path, so the e2e specs stop tripping the rule.
2026-06-09 21:52:27 -04:00
Anso 86bfc108ae fix(security): resolve open CodeQL path-injection and temp-file alerts (#1322)
Re-establish the path-containment barrier inline at the backup readdir
sink in restoreStackFiles. The sink previously built backupDir through
the getBackupDir helper, whose stack-name validation the static analyzer
does not trace, leaving a flagged path-injection sink. The barrier now
resolves backupDir against its root and asserts containment inline, the
same pattern already used in backupStackFiles. Behavior is unchanged for
valid stack names (already validated one line above by resolveStackDir).

Scope the js/insecure-temporary-file rule out of e2e/** in the CodeQL
config. End-to-end fixtures must seed files into the backend's COMPOSE_DIR
so the API under test can read them back; that path is a fixed location
under /tmp in both CI and local dev, which the rule flags. A randomized
temp directory does not apply because the backend resolves against its own
COMPOSE_DIR. Production code is still analyzed.
2026-06-05 23:02:08 -04:00
Anso d882f223f4 feat(api-tokens): switch to sen_sk_ prefixed opaque keys (#1062)
* feat(api-tokens): switch to sen_sk_ prefixed opaque keys

Replace JWT-shaped API tokens with 56-char opaque keys of the form
`sen_sk_<43-char base62 random><6-char base62 checksum>` (256-bit
entropy, sha256-truncated checksum). Node-proxy tokens stay JWTs.

Why:
* The api_token path was already a sha256 DB lookup; the JWT signature
  was wasted work and the 400d JWT ceiling vs DB expires_at was a
  confusing dual bound.
* Opaque tokens carry a verifiable checksum so malformed/typoed values
  are rejected before any SQLite lookup.
* `sen_sk_` prefix is recognizable to GitHub, TruffleHog, GitGuardian
  and makes the on-wire shape visually distinct from node_proxy JWTs.

Changes:
* New `utils/apiTokenFormat.ts` (generate + checksum-verify, CSPRNG via
  randomInt, timingSafeEqual on the checksum compare).
* `middleware/auth.ts` and `websocket/upgradeHandler.ts` route opaque
  tokens before any jwt.verify; 401 messages unified to avoid a
  token-existence oracle.
* `middleware/rateLimiters.ts` short-circuits opaque tokens in the
  node_proxy detection and keys per-token via a non-reversible sha256
  slice so each token keeps its own bucket without a DB hit.
* All six existing tests migrated from jwt.sign({scope:'api_token'})
  to generateApiToken(); new format-only test suite covering prefix,
  length, alphabet, checksum reject paths, and a 10k-iteration
  collision/integrity loop.
* Docs (features/api-tokens.mdx, api-reference/overview.mdx) describe
  the shape and drop the obsolete JWT-ceiling note.

* fix(api-tokens): clear CI lint and CodeQL false positives

* Drop unused TEST_USERNAME import in remote-console-session.test.ts;
  the migration to generateApiToken() left it orphaned.
* Add a CodeQL barrier model so `generateApiToken`'s ReturnValue does
  not flow into the `insufficient-password-hash` query. The function
  emits 256-bit CSPRNG opaque keys; sha256 of the raw token is the
  correct construction for high-entropy API tokens (bcrypt-class
  hashes target low-entropy human passwords). CodeQL's name heuristic
  was treating "Token" as a password source and flagging the standard
  sha256 wrapping at all 9 call sites.

* ci(codeql): exclude js/insufficient-password-hash for token paths

The previous barrierModel data extension was a no-op for this rule: the
js/insufficient-password-hash query identifies its "password" sources via
SensitiveExpr's name heuristic ("token", "secret", "key" substrings),
which is upstream of the taint-tracking layer where barrierModel applies.
Verified by post-push re-analysis: 9 alerts still open, all undismissed.

Replace the dead extension with a path-scoped query-filter in
codeql-config.yml so the rule no longer fires on apiTokenFormat,
apiTokens, and the test directory. Real user-password hashing code
elsewhere in the repo (auth, users, setup routes) remains analyzed.

The 9 existing alerts on PR #1062 are dismissed via API as false
positives with a justification pointing at this config. Future runs
will not re-flag them because of the path filter.
2026-05-15 18:21:28 -04:00
Anso 0dcf309c48 fix: add CodeQL barrier model for sanitizeForLog against log injection (#935)
sanitizeForLog() already strips CR, LF, and control characters from user
input before logging, but CodeQL did not recognize the custom sanitizer.
This caused false-positive log-injection alerts at every call site.

Add a CodeQL data extension model that marks the return value of
sanitizeForLog() as a barrier against log-injection taint, and a
codeql-config.yml that references it.
2026-05-06 08:47:34 -04:00