Add a global resource timeline endpoint for provider activity and wire vSphere Activity to VMware timeline changes. Seed mock VMware activity through the same supplemental-change path and keep the relevant resource contract tests current.
- Remove the redundant Signal column from the TrueNAS protection ledger
- Keep replication state visible in target detail and outcome badge
- Add a guardrail for the fitted protection table width
- Scope the TrueNAS resource fetch to native TrueNAS and agent-origin system rows
- Group overview inventory counts to keep the table scannable without horizontal overflow
- Remove internal service/resource ids from visible TrueNAS service rows
clearSession invalidated the session-store record and zeroed both
session/CSRF cookies on the client side, but never deleted the
matching entry from the CSRF store. The orphaned record stuck around
for up to 4 hours (CSRF TTL) after every logout. Not exploitable —
the session itself was gone, so the stale CSRF entry could not be
used — but the asymmetry was real: InvalidateUserSessions (password
change) and InvalidateOldSessionFromRequest (re-login) both already
called DeleteCSRFToken, and logout was the missing path.
Add the same call in clearSession alongside InvalidateSession.
Regression test TestClearSession_DeletesServerSideCSRFToken sets up a
real session + CSRF token pair, calls clearSession with a request
carrying the session cookie, and asserts the CSRF token no longer
validates against the session ID afterward.
Two hardening adjustments to renderMarkdown in aiChatUtils.ts:
- Pin ALLOWED_URI_REGEXP to ^(?:https?:|mailto:). The previous
config left href scheme filtering to DOMPurify's library defaults,
which strips javascript:/data: in current versions but is version-
dependent. Making the allowlist explicit means the safety property
survives a future DOMPurify upgrade that loosens the internal
default.
- Drop 'class' from ALLOWED_ATTR. The LLM has no legitimate reason
to apply arbitrary CSS classes, and allowing them on sanitized
markup is a UI-redress surface: an attacker who can influence the
chat output (prompt injection, compromised model endpoint) could
inject something like <div class="fixed inset-0 z-50 bg-black">
to overlay the page or hide content.
Also expanded the marked.setOptions comment to document that the
sanitizer is the real XSS gate but marked options are kept tight as
defence-in-depth: a future DOMPurify-config loosening shouldn't mean
script tags from the LLM hit the DOM.
Regression tests added in aiChatUtils.test.ts:
- javascript: hrefs stripped
- data: hrefs stripped
- class attribute stripped from <div>
- https links preserved with target=_blank rel=noopener noreferrer
RequirePermission was reading the authenticated username from
w.Header().Get("X-Authenticated-User"). The header is set by checkAuth
upstream, but response headers are mutable across the handler chain:
any middleware sitting between checkAuth and RequirePermission that
wrote that header would substitute an arbitrary identity, and the
RBAC authorizer would make its access decision against the
substituted value. Identity belongs on the request context, not in a
mutable response surface.
Read internalauth.GetUser(r.Context()) first — checkAuth already
calls attachUserContext on every auth path that produces a real user,
so the context is the authoritative source. Keep a defensive,
warn-logged fallback to the response header for the one upstream path
that sets the header but skips attachUserContext (the anonymous-user
flow), so the existing test contract is preserved while the warning
flags the source path to plug.
Regression test TestRequirePermissionUsesContextUsername constructs
the attack: a request whose context says "real-authenticated-user"
and whose response header says "evil-spoofed-user". The RBAC
authorizer must observe the context value.
handleSAMLSLO was an unauthenticated force-logout DoS: any cross-origin
GET or POST to /api/saml/{id}/slo cleared the user's session and
returned a 302 to /?logout=success. No SAML payload was required, no
signature validation, no IdP-identity check.
Add ValidateLogoutResponse to SAMLService wrapping crewjam/saml's
ServiceProvider.ValidateLogoutResponseRequest, which validates the
XML-DSig against the configured IdP certificate and the standard
LogoutResponse temporal and target invariants.
In handleSAMLSLO:
- extract and validate the provider ID
- require either a SAMLResponse (response to our SLO request) or a
SAMLRequest (IdP-initiated SLO) parameter — reject bare requests
with 400 and do not clear session
- reject IdP-initiated LogoutRequest with 400: we don't track
in-flight request IDs to bind one to yet, and silently treating it
as a force-logout signal is the same bug we are closing
- on SAMLResponse, call service.ValidateLogoutResponse. On failure
return 403 and do not clear session; audit-log the failure
- only when validation passes do we clear the session and redirect
Regression tests:
- TestHandleSAMLSLO_RejectsEmptyPayload (400, no redirect, no session
state mutation)
- TestHandleSAMLSLO_RejectsInvalidLogoutResponse (403 on garbage
SAMLResponse value)
- TestHandleSAMLSLO_RejectsIDPInitiatedLogoutRequest (400 on
SAMLRequest-only payload)
The prior TestHandleSAMLSLO_Redirects test, which asserted the unsafe
"clear session and 302 on any GET" behaviour, is replaced by the
three above.
The --archive PATH flag (and the Proxmox LXC bootstrap which
propagates --archive into the container) reached install_pulse_archive
without going through download_release_archive, which is the only
place that previously verified the cryptographic signature on a
Pulse release tarball. That made --archive a clean signature-
verification bypass: anyone who could swap a local tarball
between download and install (shared /tmp on a Proxmox host, a
maintainer testing a swapped local file, etc.) would get
arbitrary root code execution at extract time.
install.sh:
- install_pulse_archive now requires ${archive_path}.sshsig and
verifies it via the existing verify_release_signature helper
before tar runs. Missing or invalid signature is a hard
fail-closed.
- download_release_archive now keeps the .sshsig alongside the
archive at ${archive_path}.sshsig instead of stashing it in a
mktemp file that got deleted post-verify. install_pulse_archive
re-verifies it; no behavior change for the curl|bash path beyond
the extra integrity check at extract time.
- Proxmox LXC bootstrap now pct push'es ${container_archive_source}.sshsig
alongside the archive into the container so the in-container
install_pulse_archive run can verify. Missing sidecar fails the
bootstrap (cleanup_on_error tears down the half-provisioned
container).
- Cleanup paths that remove the temp archive on success/failure
now also remove the sidecar.
- --help advertises the .sshsig requirement.
install-sh-smoke.yml:
- Smoke test now pulls ${tarball}.sshsig alongside the tarball
before running install.sh --archive inside the test container.
Trade-off: maintainer flows that did `install.sh --archive
/path/to/local.tar.gz` against an unsigned local tarball will now
fail with a clear "Required signature sidecar not found" error.
Maintainers can either pull the corresponding .sshsig from the
release alongside the tarball, or skip --archive in favour of the
canonical download path.
getCookieSettings auto-set SameSite=None on the session and CSRF
cookies whenever a request arrived via a trusted proxy with
X-Forwarded-Proto: https. The original intent was "be more permissive
for proxied deployments," but in practice that disabled the
browser-side CSRF defence for every Pulse deployment behind a reverse
proxy — which is nearly all of them.
SameSite=None tells the browser to attach the cookie on arbitrary
cross-site requests. That is only required for cross-origin iframe
embedding scenarios Pulse does not document or support. The
top-level-navigation cases that actually matter (OIDC/SAML callback
landing, Cloudflare-tunnel access from a bookmark) all work under Lax,
which still attaches cookies on top-level navigations and only blocks
cross-site sub-resource requests.
Always return SameSiteLaxMode. The Secure flag still tracks the
actual connection state via isConnectionSecure(r). Together with the
earlier CSRF bypass fix (require token regardless of Authorization
header), the browser-side and server-side defences are now both in
place.
Test expectations updated: three cases that previously asserted
SameSiteNoneMode for the proxied-HTTPS / Cloudflare-tunnel / direct-
TLS-with-Forwarded paths now assert SameSiteLaxMode, with comments
flagging them as regression coverage.
Self-hosted runners attached to a public repo are a known
severe-risk pattern: any workflow that gains the ability to run
PR-controlled code escalates to RCE on the runner host. Even
though this workflow is workflow_dispatch only today, the runner
itself sits one workflow edit away from arbitrary code execution
on whatever machine it's pinned to.
Changes:
- runs-on: self-hosted -> ubuntu-24.04. Reachability of base_url
from the GitHub-hosted runner is now the dispatcher's
responsibility. Add tailscale/* steps (like deploy-v6-preview-
demo.yml or deploy-demo-server.yml already do) if pointing at
a private Pulse instance.
- Drop the admin/admin fallback on PULSE_EVAL_USER and
PULSE_EVAL_PASS. The previous default was
${{ secrets.PULSE_EVAL_USER || 'admin' }} which silently ran
the eval as admin/admin if the secrets were missing. Added a
fail-closed precheck that refuses to proceed unless both
secrets are set.
- Route inputs.scenario, inputs.models, inputs.providers, and
inputs.base_url through env: indirection instead of inline
${{ }} interpolation into shell. Defense-in-depth against
script injection from the dispatch payload.
Net result: kills the audit's only CRITICAL finding and the
companion MEDIUM (admin/admin fallback). Eval can still run
exactly as before once secrets are set and base_url is reachable.
Render managed runtime status probes as unavailable instead of 000 when a restricted shell can see loopback listeners but cannot run local HTTP probes. Refs architecture post-RC canonicalization follow-up.
Keep the managed dev runtime on loopback unless PULSE_DEV_LAN=true is set, so installed LAN agents cannot accidentally attach to a developer session. Harden managed status checks for restricted local shells. Refs architecture post-RC canonicalization follow-up.
install.sh:
- find_pulse_binary_in_dir now rejects symlinks, so a malicious
tarball that smuggled a symlink at the expected binary path
cannot redirect the cp to $INSTALL_DIR/bin/pulse onto an
arbitrary file outside the extract dir.
- tar -xzf calls in install_pulse_archive now pass
--no-same-owner --no-overwrite-dir, defending against archive
entries that try to chown extracted files or rewrite existing
directory metadata.
Demo deploy workflows:
- deploy-demo-server.yml routes inputs.target through env: so the
workflow_dispatch input cannot break out of the case statement,
even though type: choice constrains it client-side.
- update-demo-server.yml routes inputs.tag, inputs.target, and
github.event.release.tag_name through env: for the same reason.
All defense-in-depth: download_release_archive still signature-
verifies the tarball, and the demo workflows are gated to write-
access dispatchers. The argument-vs-env distinction matters once
the trigger surface or trust boundary changes.
isHostedSubscriptionValid was treating any tenant with a non-nil
TrialEndsAt as a valid bounded trial, never comparing the timestamp to
the current time. A tenant whose trial ended in the past but whose
stored subscription state was still "trial" (e.g. lease refresh failed
or never ran) retained full hosted Cloud access indefinitely.
The validity rule now requires both that a trial end timestamp exists
AND that time.Now() is before it. Refactored the switch to make the
trial branch read explicitly: nil evaluator -> deny, nil end -> deny,
past end -> deny, otherwise allow.
Added TestTenantMiddleware_HostedMode_ExpiredTrial_Blocked as the
regression test (mirrors the existing BoundedTrial_Allowed +
UnboundedTrial_Blocked pair with a past-dated trial end).
CheckCSRF was skipping the CSRF check whenever the request carried
Authorization: Bearer, Authorization: Basic, or X-API-Token, without
validating the credential. An attacker on a cross-origin page could
fetch() any state-changing endpoint with credentials: 'include' plus an
arbitrary Authorization header — the browser would auto-attach the
victim's pulse_session cookie, the server would skip CSRF, and the
request would execute as the logged-in user. Full CSRF bypass for every
session-authenticated user.
CSRF protection exists because the browser auto-attaches the session
cookie. That cookie is the only auto-attached credential we issue, so
it is the only correct signal for whether CSRF applies. Header-based
auth is set explicitly per request and is not CSRF-vulnerable, but its
presence does not make a session-cookie-bearing request safe. Skip CSRF
only when no session cookie is present; otherwise require the token
regardless of any Authorization or X-API-Token header.
Tests: the three "header bypasses CSRF" cases in security_test.go were
passing only because they sent no session cookie (so the no-cookie path
returned true). Renamed those to make the no-cookie precondition
explicit. Added TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent
in security_regression_test.go covering X-API-Token, Authorization:
Basic, Authorization: Bearer, and mixed-case Bearer with a session
cookie present — each must require a valid CSRF token.
Five steps in the release-asset validation workflow built curl
commands like:
curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" ...
That renders the token into the step's shell command line, where it
is visible to any process able to read /proc on the runner during
the curl call. Convert all nine such call sites to pass the token
via env: GH_TOKEN and reference $GH_TOKEN in the header instead, so
the token only lives in the step env, never on argv.
No behavior change; the token value is identical.
Add VM and share counts to the TrueNAS observed summary contract and systems table.
Keep TrueNAS overview inventory table-first with a platform guardrail.
Move TrueNAS apps, VMs, and network shares out of the overview stack and into first-class platform sub-tabs.
Update route and shell proof coverage for the expanded native TrueNAS tab set.
Replace the embedded recovery workspace with a TrueNAS-owned snapshots and replication table.
Keep the platform shell proof aligned to the native TrueNAS row contracts.
Replace the generic embedded storage surface with a TrueNAS-owned topology table for pools, datasets, and physical disks.
Keep pool child inventory visible by default and route rows through the shared platform detail drawer.
Scope TrueNAS child source IDs under the system source key so duplicate pool, dataset, app, VM, share, and disk names from different appliances do not merge.
Use the resulting hierarchy for per-system TrueNAS overview counts and scoped mock metric targets.