Current security model and implementation notes for Relay.
Relay is an Electron desktop application that handles operational data, local configuration, and network-backed services. The security model centers on a narrow renderer surface, validated IPC, and encrypted storage for sensitive secrets where Electron supports it.
The main process is trusted code with access to Electron, Node.js, the local file system, and OS integrations.
Primary responsibilities:
- Window creation and lifecycle
- PocketBase bootstrap and local background services
- IPC handler registration
- File system and shell operations
- Security header enforcement
- Credential and config secret handling
- Credential-free status-feed aggregation, including the public Juniper Mist API
Key files:
src/main/index.tssrc/main/app/windowFactory.tssrc/main/app/securityHeaders.ts
The preload script exposes a typed window.api bridge and is the only renderer-facing Electron boundary.
Key file:
src/preload/index.ts
The renderer runs with:
contextIsolation: truenodeIntegration: falsesandbox: true
The renderer does not import Node.js or Electron APIs directly. System-level operations go through the preload bridge. Ordinary reads and online writes use the PocketBase SDK directly. Offline-capable desktop writes use the validated preload bridge to enter the main-process mutation queue; Relay Web rejects writes while offline.
Relay Web is an optional server-mode backup path for desktop Chrome, Edge, and Safari on a managed LAN or private VPN. It serves the shared renderer and a bounded same-origin API from src/main/web/. It is not designed or approved for internet exposure.
The gateway binds only when server-mode direct LAN access and Relay Web are both enabled. Host-header and request-origin validation restrict accepted requests to the local machine name and private interface addresses. Ordinary sessions use random server-side identifiers in HTTP-only, path-scoped, SameSite=Strict cookies with one-hour idle and eight-hour absolute limits. Cookie and CSRF values rotate after refresh while a separate server-only logical-session ID preserves rate accounting and revocation ownership. Logout, replacement, and expiry destroy that logical record even when a previously authorized request overlaps cookie rotation.
The browser receives the ordinary app-user connection needed by the shared renderer only after the web session is authenticated. Renderer bearer credentials use an in-memory SDK auth store; initialization removes legacy pocketbase_auth localStorage data. Explicit sign-out clears renderer credentials and invalidates in-flight refreshes. Gateway 401 responses trigger in-place reauthentication; network failures cannot erase a confirmed authentication rejection. Protected commands keep the existing authoritative capability, revision, replay, and audit controls. Browser protected sign-in and destructive approvals are server-mediated; they do not expose Electron secrets, local paths, the protected auth store, or private signing keys.
Dispatcher Radar crosses this boundary only as a bounded, strictly validated RadarSnapshot. CW Dashboard cookies remain in the Electron Radar session on the Relay server PC and are never returned to the browser. Radar reads require an authenticated Web session. Manual refresh additionally requires same-origin and CSRF validation and is limited to 12 requests per logical session per minute. The browser cannot supply a Radar URL, cookie, credential, or alternate dashboard target.
Relay Web deliberately has no service worker, browser push subscription, permissive cross-origin API, backup/restore endpoint, arbitrary filesystem bridge, connection-reconfiguration endpoint, or offline mutation queue.
The service uses cleartext HTTP. Session credentials, operational data, and responses are not confidential against a network observer. Never port-forward or publish the Relay Web port through public DNS, a public reverse proxy, or a WAN-facing firewall rule. Restrict access to approved LAN/VPN devices. See docs/relay-web.md for deployment requirements.
src/main/utils/trustedSender.ts provides assertTrustedIpcSender, which every IPC handler calls at entry. It confirms the sender frame is the main frame of a Relay window (matching the dev-server origin in development or the dist/renderer file URL in production) and rejects anything else with a security log line. This is defense-in-depth: navigation lockdown makes untrusted senders unreachable today, but the check keeps that true if a navigation guard ever regresses.
electron-builder.yml configures Electron fuses at build time:
| Fuse | State |
|---|---|
RunAsNode |
disabled |
EnableNodeOptionsEnvironmentVariable |
disabled |
EnableNodeCliInspectArguments |
disabled |
OnlyLoadAppFromAsar |
enabled |
EnableEmbeddedAsarIntegrityValidation |
disabled |
EnableEmbeddedAsarIntegrityValidation is intentionally left off: electron-builder signs the unpacked native module (better-sqlite3) after computing the asar integrity hashes, which causes a startup integrity violation on every signed build. Re-enable once the builder computes integrity post-signing.
electron.vite.config.ts injects a <meta http-equiv="Content-Security-Policy"> tag into dist/renderer/index.html at build time. It is a defense-in-depth fallback for the packaged file:// load path, not an exact copy of the response-header policy. Because the configured PocketBase origin is not known at build time, the meta policy permits the supported HTTP(S)/WebSocket schemes in connect-src and omits the runtime header's form-action directive. A meta tag also cannot provide the non-CSP response headers. The session-level policy in src/main/app/securityHeaders.ts is authoritative at runtime: it narrows connect-src to the configured PocketBase origin, includes form-action 'self', and installs the remaining security headers.
Relay blocks unexpected navigation and secondary window creation paths in src/main/app/windowFactory.ts.
Controls in place:
- Main and auxiliary windows reject unexpected
will-navigaterequests window.open()is denied for app windows- Auxiliary windows are limited to an allowlisted route set
- Auxiliary windows are capped at 5 concurrent instances
The general OPEN_EXTERNAL action also requires a trusted sender and a shared
external-action rate-limit token. HTTPS navigation is limited to Relay's exact
status, social, Teams, or boundary-safe Dynatrace hosts; credentials, custom
ports, whitespace, control characters, and all other web hosts are rejected.
Juniper Mist incident links are limited to HTTPS URLs whose hostname is exactly
status.mist.com; lookalike and credential-bearing URLs are rejected.
Teams desktop and web URLs are further confined to the meeting-draft path,
exact subject and attendees fields, bounded decoded values, and validated
attendee addresses. Relay has no general mailto: opener.
Service Desk links use a separate, explicit-click capability so they do not broaden the general external-link allowlist. That handler accepts only bounded, credential-free HTTPS URLs without custom ports, validates the IPC sender, uses the shared external-action rate limit, and logs only an origin-safe URL description when it blocks or cannot open a link. Relay Web applies the same URL shape checks in its browser action before opening a new tab.
This capability applies no host allowlist. The Service Desk host is operator-supplied, so any HTTPS host that meets the shape checks is accepted. The trust boundary is the explicit operator click plus the shape checks, not a fixed host set.
Desktop update discovery is advisory and credential-free. The main process requests only the fixed
GitHub API endpoint for CrimsonSoul/Relay's latest release, rejects redirects, bounds request time
and response size, requires JSON, and accepts only a published normal vX.Y.Z release. Discovery
alone can display a mutable release, but installability additionally requires GitHub's immutable
flag, a commit SHA, exactly the expected ZIP and checksum assets, uploaded state, bounded sizes,
fixed asset API URLs, and valid GitHub SHA-256 digests. The renderer cannot provide an alternate
repository, URL, version, path, asset, or process argument.
Every privileged updater transition requires a separate trusted-sender-validated IPC action. A release check never starts a download; a download never starts installation; installation never restarts Relay. Electron test mode suppresses filesystem and process side effects. Relay Web does not receive discovery or updater capabilities.
Downloads stream to an exclusive .part file in an app-owned, randomized version directory under
the current user's %LOCALAPPDATA%\Relay\Updates tree. Relay applies a protected Windows DACL that
grants only the current user and LocalSystem full control, then atomically renames each verified
download to its final asset name. The downloader permits only HTTPS redirects to the fixed GitHub and
GitHub asset hosts, rejects credentials and custom ports, caps redirects and bytes, enforces the
advertised length, and calculates SHA-256 while writing. Relay requires the downloaded ZIP digest to
agree with both GitHub metadata and the separately downloaded checksum file. The ZIP must contain
exactly one regular, non-encrypted top-level Relay.exe; traversal, links, directories, case
variants, extra members, unsupported compression, size expansion, CRC failure, and a missing Windows
executable marker fail closed and remove the staging directory. The immutable-release metadata
re-fetch uses the same caller abort domain as the download. Its deadline remains armed through
bounded body consumption, so a response that supplies headers and then stalls cannot hold the updater
single-flight. Cancellation during this step restores the available state without leaving partial
assets.
The renderer cannot supply an installer path, filename, URL, command, or argument. Before every
execution, the main process resolves the recorded staging directory and installer again, rejects
symbolic links and non-regular files, confirms the exact size and Windows marker, and re-hashes the
executable. Normal installation supplies only fixed /relay-prepare-only and generated transaction
arguments to the staged executable. The native bootstrap validates that request, stages a candidate,
and writes a bound prepared receipt without closing the current Relay process.
Restart remains a separate trusted action. Server mode stops services and snapshots data; client mode checkpoints cache and pending changes. Only a completed matching request can be handed to the validated stable launcher. A valid pending prepared transaction can be restored after process loss, but invalid, redirected, mismatched, or changed metadata never exposes restart. Successful preparation removes its staging directory; healthy post-promotion startup also removes current and older recognized staging, retries after 90 seconds, and retains only newer or unrelated paths. The 24-hour rule remains the fallback for abandoned recognized staging.
If protected preparation fails on a verified protocol-1 runtime, Relay may retry once through the legacy prepare-only path after deleting the request and revalidating the installer and canonical state. Protocol-2 or malformed state cannot use that fallback. Bootstrap diagnostics accept only a bounded, non-redirected fixed-format file and an allowlisted native reason; logs omit paths, arguments, and transaction IDs.
The Windows boundary harness compiles alternate runtime and recovery-data roots only when its
explicit contract is enabled. Those roots live beneath an initially absent, owned RUNNER_TEMP
parent, while production launchers retain the normal LocalAppData and AppData paths. The updater
integration archives the target fixture, drives download, preparation, restart, promotion, staging
cleanup, and predecessor retention through the real manager and native binaries, and preserves user
data. It snapshots and restores existing Relay shortcuts and removes only its disposable parent.
Launcher timing collection requires both RELAY_BENCHMARK_EXIT_AFTER_RENDER=1 and a validated
UUID in RELAY_BENCHMARK_RUN_ID. It writes one bounded local marker after successful runtime
process creation containing only a protocol number, numeric phase durations, and a validation
count. It records no paths, hashes, credentials, settings, or operational data and performs no
upload. Runtime integrity checks remain enforced. Stable benchmark reports preserve unavailable
timings as null; native Windows measurements are required to assess startup performance.
The automated release workflow uploads the ZIP and checksum to a clean draft release without in-place asset overwrites, compares GitHub's target commit and asset digests with the verified source and locally generated bytes, resolves the tag to that source before publication, then publishes and waits for immutable state. A published release is never overwritten. Repository release immutability, branch protection, the required Build/SonarQube/Snyk gates, and the protected GitHub account are therefore part of the update trust root.
Relay release executables do not currently carry independent Windows publisher signing. The GitHub immutable release and its protected workflow provide integrity and provenance within that trust root, but they do not provide Authenticode publisher identity if GitHub or the release authority is compromised. Keep repository protections and release immutability enabled, review the fixed GitHub release when in doubt, and do not describe this path as equivalent to a publisher-signed updater. Release-note bodies are bounded and schema-validated in the main process, persisted atomically with owner-only file permissions, and rendered as React text nodes through a small Markdown subset; raw GitHub HTML and arbitrary links are never injected. Opening release details continues to use a rate-limited IPC handler. The renderer may supply only an optional normal semantic version, which the main process validates before appending it to the fixed Relay Releases URL.
Recovery is limited to packaged Windows x64 Relay. The stable native launcher and bootstrap own all runtime selection, catalog mutation, candidate promotion, server-data restoration, and fallback launching; the renderer cannot supply an executable, path, URL, command line, release tag, or commit. The protocol-2 catalog is strict and bounded: it accepts only the current build, at most three healthy predecessors, and one transaction-bound candidate. Unknown sections, duplicate keys, invalid IDs, malformed hashes or timestamps, inconsistent health, incompatible data epochs, and unreferenced build records invalidate recovery rather than widening the trusted set.
The legacy direct-activation compatibility path above is available only before a protocol-2 catalog exists. It cannot activate from a retained runtime, bypass a changed catalog, create a recovery checkpoint, or manufacture rollback history. Its native bootstrap activation retains the previous runtime, and a later protected update establishes the protocol-2 recovery baseline.
Runtime roots, recovery metadata, repair staging, and server snapshots are re-resolved beneath their
fixed app-owned parents. Relay rejects symbolic links, reparse points, non-direct children, changed
executables, and incomplete markers. A protocol-2 runtime marker records SHA-512 hashes for the
executable, every shipped Electron DLL, application archive, PocketBase executable and privileged
hook, better-sqlite3, and Koffi; its own SHA-512 must agree with the catalog along with build ID,
version, tag, full commit, recovery protocol, and server/client data epochs. Catalog and request
updates use private directories plus write-then-rename activation; the native launcher serializes
mutation with a no-sharing lock. Cleanup fails closed when the catalog, roots, marker, or transaction
state cannot be proved and never deletes a referenced runtime or snapshot.
Server snapshots are taken only after PocketBase and server-owned services stop. Relay rejects redirected or unsupported entries, scans the source and copy, requires free space for twice the data size plus a 512 MiB margin, writes the completion marker last, and atomically activates the snapshot directory. Native restoration validates the transaction and source identity, journals the swap, and keeps the displaced live directory until the authoritative snapshot has become live and the activated catalog proves the intended runtime is current. Only then may the launcher remove the displaced tree; a retained journal retries that cleanup after interruption. Client mode does not rewind shared server state; it closes successfully checkpointed local cache and pending mutation databases before the runtime transition. A rollback is blocked when either data epoch differs.
Candidate health is established by the stable launcher, not by the candidate declaring itself
current. The candidate receipt is accepted only for the active transaction after renderer mount,
local startup completion, and 60 seconds of relevant data-plane health. The native supervisor uses
a shared 195-second process deadline and at most two attempts. During probation Relay disables its
normal crash watchdog, window reload, and process auto-relaunch behavior; packaged Windows
PocketBase runs in a kill-on-close Job Object so a failed candidate cannot leave the embedded server
behind. A failed candidate's exact immutable tag@commit fingerprint is retained in a bounded
history and suppressed by future update checks rather than suppressing an unrelated later commit at
the same semantic version.
Immediately before promotion or automatic rollback activates its terminal catalog, the launcher atomically writes a settlement intent bound to that update transaction, source build, target build, and outcome. If the launcher is interrupted after the catalog commit, the next startup validates the intent, request, catalog identity, target metadata or failed-release fingerprint, and absence of an active candidate or transaction before removing the stale request. This reconciliation runs before server-data cleanup so a long cleanup cannot indefinitely leave the recovery UI busy.
Reading recovery state still requires trusted-sender IPC. Repair and rollback additionally require an active Owner session, fresh password reauthentication, bounded validated input, and the shared reauthentication rate limiter; GitHub repair also consumes the network-action limit. Repair resolves the exact retained version and full commit from the fixed Relay repository, requires an immutable release and the normal ZIP/checksum/digest checks, and asks that historical bootstrap to recreate only its matching catalog-bound runtime. If the saved installer hash is known, it must match too. The bootstrap receipt is bound to the transaction, build, version, commit, runtime hash, and installer hash. Repair never promotes a build, changes current data, replaces the stable launcher, or rewrites the catalog.
The native Recovery shortcut prefers retained builds and the normal launcher falls back to them when the current runtime is unusable, keeping the recovery screen reachable when the current Electron bundle is broken. If no validated runtime starts, only the fixed Relay Releases URL is opened. This fallback inherits the release trust limitation above: releases are immutable and digest-verified, but not independently Authenticode-signed.
Live problems are server-owned reads through the same-environment platform Problems API v2 endpoint.
The existing encrypted platform token needs environment-api:problems:read; its owner needs
environment:roles:viewer. The renderer never receives the token. Requests reject redirects, encode
selectors and identifiers, bound response bytes and pagination, enforce deadlines, and respect rate
limits. No receiver, public exposure, queue, new IPC channel, or additional secret is introduced.
Existing server/client connections and ordinary PocketBase read permissions remain unchanged.
Live DQL candidates come from the explicitly configured standard workflow through read-only Automation
API requests using automation:workflows:read. Relay validates source availability, active event
trigger, and throttling. The source trigger must cover the intended scope. Relay does not create,
modify, enable, or run workflows or tasks. It reads the actual triggering params.event, including
while the execution is RUNNING. Only DAVIS_PROBLEM payloads from the requested workflow are eligible.
Raw payloads are size-bounded and passed as an escaped JSON string to a data DQL command; event text
cannot become executable query syntax. The administrator's expression is separately validated as a
per-event filter with no pipelines, subqueries, comments, or control characters. Returned execution
IDs must belong to the submitted batch. Failed evaluation does not advance the cursor. Only bounded
presentation fields and execution references are retained; other event/definition/task data is not
persisted or logged. Admission decisions reset on environment, token, workflow, or matcher changes.
API state remains authoritative, so stale workflow snapshots cannot reopen closed problems.
Historical Grail reconciliation retains storage:events:read and relevant bucket access. It runs
independently of live reads; serialized commits preserve newer live state and scope eligibility.
Scope and credential changes invalidate stale writes. Backup restore pauses and drains every sync
path. Incomplete historical results cannot remove eligibility or trigger destructive reconciliation.
Local notes, addressed state, IDs, relationships, and backup-gated retention retain their existing
contracts.
Email names are optional presentation metadata saved after the canonical problem. A configured
workflow provides direct execution references; otherwise Relay reads existing noc.notification
business events from noc-workflow (storage:bizevents:read plus bucket access). A background job runs
at most once a minute, with one ten-second deadline, four concurrent execution reads, and at most 25
uncached executions. It does not repeatedly scan Grail every second. Deadline expiry aborts reads;
completed partial subjects can be saved, but late or stale responses cannot write.
Task inputs contain recipients and bodies as well as the subject. Only the validated subject is retained, cached, or persisted; no task inputs are logged. Cache context includes environment and credentials. Subjects are limited to 1,000 characters and rendered as text. They can only update existing in-scope problems with newer naming metadata and cannot change lifecycle, expand scope, or write back to Dynatrace. A subject is displayed only when its recorded status matches the canonical status. Failed reads and expired execution history retain the existing fallback.
Dynatrace dashboard popouts are handled by src/main/dynatrace/DynatraceWindowManager.ts.
Security controls in place:
- The Relay chrome shell is loaded from the trusted renderer URL or packaged renderer file only
- Dashboard content is loaded into a separate
WebContentsView - Dashboard content uses the isolated
persist:relay-dynatracesession partition - Permission requests and permission checks from the dashboard session are denied
- External navigation is limited to HTTPS
dynatrace.comhosts and Microsoft authentication hosts required for SSO window.open()from dashboard content is denied; allowed Dynatrace or Microsoft auth popups are loaded in the same dashboard view- Permission-denial logs and public dashboard runtime state retain only URL origins; paths, query strings, fragments, and URL credentials are discarded before logging, IPC, or Relay Web publication
- Blocked navigation logs use the same origin-only URL descriptions to avoid leaking dashboard query strings or auth details
- Settings can clear the Dynatrace dashboard session when operators need to force reauthentication
src/main/app/securityHeaders.ts installs CSP and related response headers on the default Electron session.
Highlights:
default-src 'self'- Strict
connect-srcallowlist for PocketBase endpoints object-src 'none'base-uri 'self'form-action 'self'
Development mode relaxes script-src only as needed for HMR.
Shared IPC schemas live in src/shared/ipcValidation.ts. Handlers validate input before acting on it and return safe failures for invalid payloads.
Related files:
src/shared/ipc.tssrc/shared/ipcValidation.tssrc/main/handlers/ipcHelpers.ts
File operations validate paths before touching disk.
Key files:
src/main/utils/pathValidation.tssrc/main/utils/pathSafety.ts
Wiki publishing has no configured source root or watched folder. The native file picker returns selected paths only to the main process. Relay accepts regular, non-symbolic PDF files within the size limit, records their canonical identity, and reopens them with no-follow semantics. Before every bounded chunk read it revalidates the canonical path, device, inode, size, modification time, PDF signature, and whole-file checksum. A moved or changed source becomes source-required instead of uploading replacement bytes under an existing manifest.
src/main/handlers/cacheHandlers.ts restricts cache access with explicit allowlists.
Checks include:
- Collection name allowlist
- Mutation action allowlist (
create,update,delete) - Record shape validation for writes
cloud_status_snapshot and cloud_status_mist_snapshot are authenticated-read,
server-owned PocketBase collections. Desktop cache read, realtime, and snapshot
paths may mirror both collections, but neither is included in the offline-mutation
or user import/export allowlists. The Mist adapter fetches only the public
credential-free status.mist.com API and sends no Relay or third-party secrets.
Company and client logos are limited to 2 MiB of compressed input and accepted only as PNG, JPEG, or WebP. The shared main-process image pipeline applies explicit source width, height, pixel, and decoded-byte budgets before full decode, resizes inside a 400 by 400 pixel box without enlargement, and bounds the generated PNG before persistence or response construction.
src/main/rateLimiter.ts provides global and caller-keyed token buckets. Security coverage is determined by handler call sites, not merely by a configured bucket.
Currently enforced limits include:
| Boundary | Enforced operations |
|---|---|
| Global IPC buckets | Native file/shell actions, Wiki source selection/staging, release-page opening, update installation/restart, and Wiki external-link opening (fsOperations); cloud-status refreshes, release checks, and update downloads (network); renderer log forwarding (rendererLogging) |
| Keyed privileged buckets | Protected login, pairing-code verification, signed commands, and the separately budgeted Wiki upload command plane |
| Relay Web route buckets | Per-address session login and per-session refresh, operational mutation, protected-command, Wiki file/search/upload, and browser-log routes |
fileImport, dataMutation, and dataReload are defined as reusable global buckets but have no current production call sites; do not rely on those definitions as enforced controls. Global and privileged denials are logged without the opaque caller key. Relay Web returns HTTP 429 with Retry-After.
Relay's pull-request and main-branch controls are defined by the checked-in workflows. The required jobs are:
- Release-compatible pull request title: validates the squash-merge title in a lightweight pull-request-only workflow, including after title edits.
- Build quality gate: formatting, linting, type checking, dependency audit, unit and renderer coverage, cache integration tests, focused real-PocketBase replay verification, Electron/browser workflows, and the production build.
- SonarQube quality gate: first-party quality and security analysis, imported unit and renderer coverage, unresolved-issue enforcement, and validation of the exact uploaded analysis.
- Snyk security gate: high- and critical-severity Open Source and Snyk Code findings, including development dependencies. Only a merged
mainpush publishes the canonical monitored snapshot formain.
The mandatory workflow-tests job runs the focused disposable-PocketBase replay test and both
npm run test:electron and npm run test:web on every Build invocation. Exact-tree reuse cannot
skip that job or replace its result: only success allows the Build aggregate and its dependent
release gate to pass. The suites run sequentially through npm to restore the native-module ABI
between them, and the job inherits read-only repository access without scanner secrets.
The Sonar and Snyk CI wrappers classify every run as one of four outcomes:
| Outcome | Meaning | Merge effect |
|---|---|---|
| Clean | The scanner completed and produced no blocking finding. | Required job succeeds. |
| Finding | A completed scan or quality gate produced a blocking finding. | Required job fails. |
| Unavailable | The scanner produced no decision because a documented outage occurred. | Snyk fails; Sonar warns. |
| Configuration | Credentials, scope, identity, response, or an unknown failure is unsafe. | Required job fails closed. |
A completed finding is a release blocker. A documented scanner outage may be classified as Unavailable so CI can distinguish missing evidence from a negative decision, but an Unavailable result is not a clean scan. Snyk reports the outage and fails closed because only clean evidence may be reused; Sonar retains a warning outcome and must be retried before release. Missing credentials, authorization failures, malformed responses, identity drift, and unknown errors fail closed as Configuration failures.
Pull-request scans do not change Sonar issue state or the Snyk monitored snapshot. A merged main push may apply the pinned Sonar review manifest. It also refreshes the main Snyk snapshot; when exact-tree reuse supplies validated finding evidence, a separate monitor-only phase performs that snapshot refresh. The Sonar reconciler validates issue identity and metadata before writing, refuses unknown open findings, is idempotent, and fails closed on drift. Scanner output is bounded and redacted; scanner tokens belong only in GitHub Actions secrets.
Credentialed Sonar and Snyk scans run only for same-repository pull requests. Repository write access therefore crosses the CI scanner-secret trust boundary and must be limited to trusted maintainers whose branches may execute with those credentials. Fork pull requests are excluded from credentialed scanner jobs and do not receive the scanner secrets.
CodeRabbit review is requested manually with @coderabbitai review while the public repository is ineligible for automatic review. Its findings remain blocking through review state and unresolved review conversations even though CodeRabbit availability is not a required check. GitHub dependency alerts, automated dependency security fixes, secret scanning, and push protection should remain enabled.
Treat any failing gate as a release blocker until the finding is validated and fixed or a narrowly documented exception is approved. Run a Codex Security standard scan before releases and after changes to authentication, IPC, Relay Web, updates, file handling, or privileged commands. Use a deep scan for major trust-boundary redesigns or when a standard scan identifies a plausible multi-stage attack path.
The approved Snyk exception for SNYK-JS-NODEFORGE-19635204 expires on September 14, 2026 and applies only to @sonar/scan@5.0.0 > node-forge@1.4.0. Relay has no production dependency on this library. Sonar uses it to extract certificates from a local PKCS12 truststore; HTTPS verification uses Node TLS rather than the affected RSA signature verifier. Remove the exception when an upstream fixed npm release becomes available; other dependency paths and findings remain blocked.
src/main/config/AppConfig.ts stores the Relay connection passphrase encrypted with Electron safeStorage when available. Packaged builds fail closed and refuse to write the passphrase when OS encryption is unavailable. A plaintext compatibility path exists only in unpackaged development and test environments such as headless CI.
Settings displays the local server URL and passphrase so operators can connect Relay clients without hunting through config files. Treat that screen as sensitive local operator context and avoid sharing screenshots that expose real passphrases.
src/main/credentialManager.ts handles proxy and auth credential caching.
Current behavior:
- Credentials are encrypted with
safeStoragewhen supported - Authentication requests are bound to a one-time nonce
- Cached credentials expire and are pruned automatically
Relay data is stored in PocketBase's SQLite database. This database is not encrypted by Relay itself.
Recommended deployment assumption:
- Use full-disk encryption when the workstation or server handles sensitive operational data
New server setup enables direct LAN access by default and binds PocketBase to 0.0.0.0. Clear Allow direct LAN access during setup to bind only to 127.0.0.1. A LAN-bound server accepts connections on every interface permitted by the host firewall, so use it only on trusted operator-controlled networks and restrict the PocketBase port to approved stations.
Client setup accepts HTTPS Relay server URLs by default and also supports HTTP for trusted LAN targets, including private IP addresses, .local names, and single-label machine names used for NOC desktop-to-laptop deployments. Public HTTP requires the explicit insecure HTTP opt-in. Use HTTPS when Relay traffic leaves the trusted LAN so the shared Relay passphrase is not sent over cleartext WAN links.
When the server is LAN-bound (0.0.0.0), Relay advertises a _relay._tcp service via mDNS, and the service name includes the machine hostname. Advertisement stops when the instance is reconfigured to client mode, rebound to loopback, or quit. Discovery results shown during client setup are filtered to private/LAN addresses, so an mDNS advertiser cannot present a WAN address as a local Relay server.
Relay manages its own collections at startup. Bootstrap creates missing Relay collections, adds missing fields, and re-applies authenticated API rules to existing managed collections.
Unknown collections are left in place and logged as unmanaged. Startup must not delete application or operator-created collections outside Relay's managed collection list.
The packaged server also loads Relay's checked-in PocketBase hooks from a dedicated resource directory. Startup fails closed if the privileged reauthentication hook is absent or does not answer an unauthenticated probe with the expected authorization failure.
PocketBase first binds to loopback while Relay authenticates the configured superuser and persists the authoritative authentication and privileged-reauthentication rate limits. Only after those controls succeed does Relay stop that bootstrap process and start the configured LAN listener.
Ordinary Relay app-user password authentication is coordinated in the main process. Concurrent connection, Wiki search, PDF, cover, and reconnect-sync consumers share one detached authentication request, then receive a validated in-memory token snapshot in their own PocketBase clients. The coordinator is bounded, keyed by a process-randomized credential digest, actively expires completed snapshots after four seconds, and is cleared on configuration or server lifecycle changes. It never copies a superuser or protected-role token, and definitive credential rejection stops retrying immediately rather than spending the remaining authoritative rate-limit budget.
If PocketBase definitively rejects the configured superuser credential, Relay stops the server and uses a one-use migration in a deterministic, owner-only directory beneath the operating system's per-user temporary directory. On Windows, Relay uses Windows PowerShell 5.1 to atomically create and then verify a protected DACL that grants full control only to the current user and LocalSystem, with inheritance for the migration and handoff files. If that operation is blocked or unsupported, repair stops before any secret is written. The passphrase is written with exclusive creation to an owner-only handoff file; it is not copied into command-line arguments, environment variables, logs, or migration source. The migration removes the handoff before changing the record and writes a nonsecret, run-specific completion marker after the save. Relay requires that exact marker, removes all repair artifacts, restarts PocketBase, and authenticates with the configured credential before bootstrap can continue.
Ordinary Relay use has no account selector and requires no role-account sign-in. Reading shared data, composing bridges, and making ordinary operational updates remain available through the shared app session. New ordinary records are unattributed; a protected role account is required only for administration and Wiki publishing.
Protected identity is username-based. relay_privileged_accounts.username is the sign-in identity; display names are presentation-only and email is not an accepted login or recovery identity. PocketBase may retain an internal @relay.invalid email value because the collection is an auth collection, but Relay enables password authentication only for normalized usernames.
Relay has three effective roles. The singleton relay_privileged_state.ownerAccountId makes exactly one Administrator record the Owner. Other active Administrator records have the Administrator role. The optional publisherAccountId makes zero or one Publisher record the effective Publisher. Account IDs—not usernames, display names, legacy operator IDs, or renderer role claims—bind sessions, commands, pairing records, authority pointers, and revocation.
The privileged PocketBase client is created in the main process with an independent in-memory auth store. The privileged token never replaces the ordinary Relay app-user token and is not exposed to preload consumers, renderer state, local storage, logs, exports, cache snapshots, or the offline mutation queue. Password values are bounded, passed only for the awaited authentication request, cleared from the form, and not retained by Relay.
Privileged sessions lock after 15 minutes without privileged activity. Disconnecting, reconfiguring, explicitly locking, signing out, or closing Relay clears privileged auth state. Normal browsing and note-taking do not keep a role session alive. Sensitive mutations can require a fresh password reauthentication proof; proofs are account/device-bound, expire after five minutes, and can be consumed once.
On a paired client, reauthentication is performed by the authenticated
POST /api/relay/privileged/reauth PocketBase hook rather than by a client-authored signed command.
The server validates the password against the current active account, derives the current effective
role, verifies that the named paired device is active for that account, and creates the proof record
transactionally. The route has a 4 KiB body limit and a dedicated authoritative PocketBase rate
limit. Remote signed envelopes cannot contain the internal privileged.reauth.confirm command. On
the Relay server PC, the equivalent proof remains available only through the trusted local command
processor.
The server hook and the paired-client call are one protocol version. Deploy the server and paired clients together before relying on fresh-password protected actions: a new client cannot obtain a proof from an old server, and a new server deliberately rejects the old self-attested confirmation command. Ordinary Relay connectivity is unchanged during that coordinated rollout.
Client workstations use a P-256 signing key generated in the main process. The private PKCS#8 material is stored only as Electron safeStorage ciphertext with owner-only file permissions where supported. If OS encryption is unavailable or the registry is corrupt, Relay requires pairing again instead of falling back to plaintext. The server stores only the public JWK, fingerprint, device label, hostname snapshot, state, and revision.
Pairing is initiated from the Relay server PC by an authenticated Owner or Administrator with devices.manage. The server creates an eight-character human code backed by a high-entropy secret; the challenge expires after 10 minutes, is account-bound and single-use, and locks after repeated failures. A successfully paired client keeps its private key locally. Revoking a device on the authoritative server record causes subsequent signed probes and commands to fail without needing access to that laptop.
Every remote privileged request is a canonical, typed envelope containing the command name, payload hash, request ID, account, device, role claim, optional expected revision, issuance time, expiry, and signature. The server:
- validates shape, size, clock skew, and the 90-second maximum lifetime;
- loads the current account, singleton authority state, and device records;
- derives effective capabilities from those records instead of trusting the claimed role;
- verifies the device fingerprint and ECDSA signature;
- claims the unique request ID before running an allowlisted handler; and
- stores only a bounded safe result or generic error.
Signed command payloads are hidden from account API reads, projections, filtering, and realtime. Only the server worker can read the transient signed body; terminal processing removes it, and bootstrap scrubs retained payloads on historical terminal records. Bounded result/error fields remain available for idempotent polling without retaining submitted secrets.
Matching retries are idempotent. Conflicting request-ID reuse, expired requests, stale revisions, disabled accounts, role changes, and revoked or unknown devices are rejected. Privileged commands are online-only; they never use Relay's offline write queue. The server PC may execute the same typed handlers without a device signature only after server-mode, trusted-sender, and active-session checks.
The server PC is the recovery trust boundary. Fresh bootstrap creates inactive ryan / Ryan Bledsoe and charles / Charles Gibbs Administrator records, points ownership to Ryan's account ID, and gives neither account a usable default credential. Initial password setup, activation, password reset, and recovery are server-local workflows. Relay has no email reset, remote recovery, or recoverable default password. Password replacement increments credential state and revokes paired sessions for that account.
Only an active Owner or Administrator may load the administration snapshot. Owner-only account commands are enforced again in the main-process handler: an Administrator cannot create, rename, activate/deactivate, or transfer ownership among Administrator accounts. Administrators may manage the Publisher account and assignment. A Publisher session can invoke Wiki management commands only. Ordinary app-user credentials cannot list or mutate protected accounts.
| Protected action | Effective role | Additional requirement |
|---|---|---|
| Administration snapshot | Owner or Administrator | Active account session |
| Administrator create/rename/status | Owner | Current account/state revision |
| Ownership transfer | Owner | Current state revision and single-use reauthentication proof |
| Publisher create/rename/status/assign | Owner or Administrator | Current account/state revision; assignment may require reauth |
| Device rename | Owner or Administrator | Current device revision |
| Device revoke | Owner or Administrator | Current device revision and single-use reauthentication proof |
| Dynatrace URL/profile replacement | Owner or Administrator | Current setting revision |
| Dynatrace token replacement | Owner or Administrator | Current setting revision and single-use reauthentication proof |
| Wiki document management | Owner, Administrator, or Publisher | Active protected session |
The administration snapshot exposes only Configured or Not configured for secrets. Replacement fields start blank and are cleared on submit, cancellation, failure, session lock, and unmount. No endpoint reveals an existing password or token. Device views expose only a short fingerprint suffix; public keys and signing metadata remain server-side.
Publisher assignment is exclusive: the singleton authority record contains zero or one Publisher account ID. Assignment never converts an Owner or Administrator into a Publisher. Reassignment validates the Publisher record, reserves the next assignment revision, revokes the previous Publisher's devices/sessions, and leaves the incoming Publisher pending server-local credential setup.
Revoking a paired device changes the authoritative server record immediately. The next signed probe or command is rejected even if the laptop retains its encrypted local key. Credential changes likewise revoke all paired devices for that account. Files on another workstation are never remotely deleted.
Server configuration remains an exhaustive allowlist. Dynatrace environment URL, platform-token replacement or removal, and alerting-profile names are typed and revision-checked. Legacy unprotected mutation endpoints fail closed and direct operators to Administration; connection testing cannot silently reuse a stored token. Connection paths, backup destinations, restore files, folder/executable pickers, and arbitrary settings objects are not remotely callable and remain local to the managed Relay server PC.
Privileged requests reuse the configured PocketBase endpoint, authentication, and realtime channel; Relay opens no additional inbound port. On a trusted HTTP LAN, signatures provide request authenticity, integrity, authorization, and replay resistance, but they do not encrypt passwords, pairing codes, metadata, or responses. HTTP therefore does not provide confidentiality. Keep this deployment on the managed trusted LAN and use HTTPS if traffic crosses that boundary.
The legacy relay_operators roster is migration input, not a runtime identity source. RoleAccountMigration plans and validates the whole conversion before privileged runtime can start. It preserves protected-account IDs, paired-device bindings, account-ID authority pointers, and every non-empty historical attribution field. The known relay_login_roster view must match its exact legacy query; ambiguous identities, roles, references, or schema defer the migration instead of guessing.
Before upgrading an existing installation, make a consistent PocketBase or SQLite online backup; never copy a live data.db alone while WAL activity is possible. Exercise the upgrade against a disposable copy first. Verify the single Owner, Administrator and optional Publisher roles, authority pointers, paired-device account IDs, historical attribution, and removal of the two legacy collections only after successful conversion.
Keep the full pre-migration backup until those invariants pass on the upgraded installation. If planning defers or a post-conversion check fails, stop Relay and restore the entire backup before starting the previous build. Do not hand-edit collections or run an older build against a partially converted database. The implementation and regression cases live in src/main/privileged/RoleAccountMigration.ts and src/main/privileged/__tests__/RoleAccountMigration.test.ts; see docs/DEVELOPMENT.md for disposable-data testing rules.
Managed Wiki metadata is read-only to ordinary clients. knowledge_documents and knowledge_categories permit authenticated reads but no direct client mutation. Publishing, category management, and deletion use allowlisted server commands that rederive the caller's Owner, Administrator, or Publisher capability and enforce revision, uniqueness, membership, and reassignment rules. Wiki mutations never enter the ordinary offline replay queue.
Uploads are account- and device-bound, size-limited, chunked, and resumable. The server binds every chunk to its batch and upload, rejects non-integral or out-of-range indexes and incorrect actual file lengths before storage (including batched creates), verifies chunk and whole-file checksums, validates the PDF signature and size, performs bounded extraction, and removes expired, cancelled, or successfully published staging data. The main-process queue never exposes source paths or PDF bytes to the renderer. Persisted paths use Electron safeStorage in an owner-only file; without OS encryption the queue remains memory-only.
PDF, cover, and desktop PDF-download reads cross narrow, trusted-sender-validated preload methods. Requests carry only bounded document IDs, checksums, and, for an explicit download, a path-free authored PDF filename. The main process obtains short-lived file authority internally, streams through hard size limits, validates signatures, sizes, and checksums, and promotes cache files atomically. Desktop downloads use a main-process Save As dialog and return only a bounded outcome; renderer code cannot choose the destination path. Tokens, server URLs, paths, and credentials never enter renderer responses.
PDF parsing and cover generation run through bounded workers. Relay uses the bundled PDF.js runtime with automatic fetching and streaming disabled, and it does not enable forms, attachments, arbitrary annotation actions, printing, PDF.js download controls, cloud OCR, telemetry, or browser PDF plugins. Relay's separate operator-initiated download action reuses the verified PDF service: Desktop writes only to a path returned by Save As, while Relay Web creates a browser download only after the authenticated same-origin route returns the expected checksum. PDF.js link and action data is inert until Relay's resolver reclassifies it. Native destinations remain in-document; unsupported schemes and local paths do not gain filesystem or execution authority. Only an explicit operator click on a resolved HTTP(S) link can reach the rate-limited, trusted-sender-validated external-link handler.
Full-text search is optional derived data. The server owns knowledge_search_chunks; authenticated clients may read it but cannot mutate it directly. Indexing and search are bounded and failure-isolated so a search outage does not weaken publication or PDF access controls. Text extraction allows at most 250,000 UTF-16 units per page, 8,000,000 per document, and 1,000,000 text items. Passage construction remains inside the timed worker with a 256 MiB heap and its own passage budget. Extracted passages are duplicated operational content in PocketBase, desktop snapshots, and backups, and Relay does not encrypt those stores itself.
Knowledge metadata may use the normal read-only offline snapshot. PDF and cover caches are content-addressed, checksum-validated, bounded, and disposable; cache reads, admission, and cleanup are serialized, active documents are protected during eviction, and a full or unwritable optional cache does not fail an otherwise verified download. They are never authority for the managed library. Server backups include authoritative managed documents and derived search data, but local caches and resumable upload queues require no restore. These controls provide integrity and resource limits, not encryption. Use managed-device controls and full-disk encryption for confidential runbooks, and HTTPS whenever traffic leaves the trusted LAN boundary. See docs/knowledge-base.md for operator behavior and docs/architecture.md for the complete data flow.
Servers sync is an explicit renderer-side PocketBase operation, available to connected desktop
and Web clients under existing collection permissions. Its guarded POST /api/relay/servers/sync
route adds no IPC channels, schema changes, or offline replay. A complete-file preview captures the client, account, current
records, and exact removal IDs. Apply rejects stale snapshots and rechecks connection/account
immediately before each batch; removals only begin after successful saves. Each batch is atomic,
but the full operation is not. The server compares each reviewed public record and revision
inside the write transaction, retaining same-timestamp peer edits and rolling back the entire
batch on a mismatch. The route accepts at most 100 operations, resolves modifiers, removes hidden
and server-maintained fields, and enforces ordinary collection rules and form validation. A
missing route on an older server blocks sync until that server is updated. Failures stop subsequent batches and
require a new preview. Unconfirmed batches are reported as uncertain, never automatically retried.
The optional pre-sync JSON download contains current server metadata and fields and remains an
operator-managed local export. It does not replace the full recovery archive described below.
src/main/handlers/backupHandlers.ts validates backup filenames before restore and rejects traversal attempts.
Scheduled maintenance requires an existing successful regular backup less than 24 hours old that passes disposable restore verification before destructive retention. Authentication, creation, disk pressure, or verification failure pauses cleanup. Attempts, sanitized failures, retry due time, and the last successful and verified archives persist atomically in operator-owned backup-health.json; incomplete attempts become visible failures after restart. Retry delays are 15 minutes, 1 hour, then at most every 6 hours; success returns to daily maintenance. Stopping or reconfiguring maintenance cancels pending retries. Automatic Dynatrace history pruning uses the same verified-backup gate while polling continues. Alert retention uses a superuser-only server transaction that selects and deletes together: unpinned alerts older than 90 days expire, then the newest 50 unpinned and 100 pinned alerts remain. A pin acknowledged before pruning is evaluated in that transaction.
Each new regular backup is verified (therefore at least weekly), and server Data administration provides Verify backup and Retry backup actions with outcomes and restore-point age. Verification restores ZIP contents into a private disposable directory on the data filesystem, checks entry CRCs and SQLite integrity/table readability including unknown collections, and never executes archived files, restores the live database, or opens a listener. A dedicated Electron utility process bounds the check to 120 seconds, 100,000 entries, and 8 GiB of extracted data; the parent sends a forced OS kill at the deadline and waits for process exit before cleanup, including when SQLite is inside synchronous native work. Verification paths are delivered only through the private parent message channel; the worker rejects standalone CLI execution and requires an absolute disposable verification destination. This process works with the packaged RunAsNode fuse disabled. This demonstrates disposable data readability, not a successful full live server restore. Continue testing full server recovery independently.
Full desktop restore verifies both a new pre-restore safety archive and the selected archive before
stopping services. Relay stops the web gateway and privileged runtime, pauses and drains background
data writes, then stops PocketBase, requiring a confirmed process exit before replacing pb_data with the private verified
extraction. It uses this stopped-file path on both macOS and Windows; PocketBase's asynchronous
restore endpoint is not a completion signal. Existing backup archives, certificate cache, and
lost+found are preserved outside the archive replacement. IDs, relationships, unknown collections,
and authoritative files come from the selected archive. Archived code is never executed by verification.
A validated local journal and retained original data directory protect the replacement until the restored server starts successfully. Failed startup restores the original directory and attempts to restart it, while returning failure to the caller. Cold startup recovers an interrupted uncommitted replacement before opening PocketBase; malformed or ambiguous recovery state fails closed. A committed journal only permits cleanup of the retained original. Restore success is returned only after replacement and server startup complete. The Electron regression checks immediate authenticated reads of original IDs, unknown collections, and files after the response.
Backup preflight reserves at least 512 MiB plus estimated uncompressed live data size. Insufficient space or ENOSPC pauses deletion and prompts the operator to free space outside Relay history and backups. Regular and pre-restore safety archives retain independent budgets of 10 and 3, pruned only after verified replacement; the known good archive and selected restore source stay protected. Create, verify, and the entire restore/restart transaction are serialized. Pre-existing archives are unverified until explicitly checked; filenames and modification times alone never authorize retention. These status and verification APIs remain trusted local desktop operations and are absent from Relay Web.
The offline cache and replay pipeline live in:
src/main/cache/OfflineCache.tssrc/main/cache/PendingChanges.tssrc/main/cache/SyncManager.ts
This design provides:
- Read fallback while PocketBase is unavailable
- Queued writes for reconnect scenarios
- Conflict logging via the
conflict_logcollection
Queued updates and deletes use the authenticated POST /api/relay/offline/replay route in the
existing integrity-verified relay_privileged_reauth.pb.js hook. It accepts only allowlisted base
collections and update/delete actions, applies the normal collection API rule, and validates
update fields through PocketBase's record form, resolving modifiers before rule evaluation and
filtering hidden fields. Comparing the observed updated revision and canonical public-record
SHA-256 fingerprint with the mutation occurs within one transaction. The fingerprint also catches
concurrent changes that share a millisecond timestamp. A concurrent server edit returns a conflict
without consuming the pending change. Ordinary CRUD routes, collection rules, IDs, and relationships
remain compatible with older clients. New clients receiving 404 from a server without this route
retain pending updates/deletes and require a server update before replay; they do not fall back to
an unguarded write.
Relay Web collection readiness is tied to a monotonically increasing connection generation. Disconnects and PocketBase client replacements close the write gate and invalidate outstanding list fetches, so a stale pre-transition response cannot install data or reopen browser writes.
src/main/app/errorHandlers.ts installs process-level guards.
Current behavior:
- Packaged Windows builds notify open windows and automatically relaunch after the first fatal uncaught exception unless fatal relaunch is explicitly disabled
- Other platforms and unpackaged runs show a blocking dialog with
QuitandContinue - Repeated unhandled rejections within a rolling window trigger a renderer stability warning
Structured logs are redacted before persistence.
Key file:
src/shared/logRedaction.ts
The redaction layer strips common sensitive fields and scans strings for PII such as emails and phone numbers.
Blocked-navigation and blocked-window-open log lines record the origin of the attempted URL only (via describeUrlForLog in src/shared/urlSecurity.ts), not the full URL. This avoids inadvertently logging tokens, session IDs, or other data carried in paths or query strings.
Authenticated browser log messages retain their browser provenance prefix and escape CR, LF, and Unicode line separators before entering line-oriented sinks. One accepted browser event therefore produces one physical log record while preserving delimiter content as visible escaped text.
- Do not expose new Electron or Node.js APIs directly to the renderer
- Validate all new IPC payloads in shared schemas before handling them
- Keep file-system access in the main process and run it through path validation
- Escape user input in PocketBase filter strings
- Log security-relevant failures without logging raw secrets
Report security vulnerabilities privately to the project maintainers. Do not open public issues for exploitable security bugs.