Secure Development
Secure Development
Developers must consult Coding Standards for panic policies and design boundaries, and Error Handling for rootcause/thiserror patterns and the full decision guide. Security-sensitive changes should also reference:
- PKI and certificates
- Secrets and encryption
- Reverse proxy security
- Filesystem and dependency security
- CLI output formatting
Document any new behavior or configuration in the appropriate docs/ area and ensure tests cover both success and failure paths.
Plugin Input Validation
Plugins are a security boundary — they interpolate user-controlled values (package identifiers,
version strings) into system commands. All input that flows into CommandSpec arguments must be
validated at the plugin level before command construction.
Package identifier validation
Every plugin that accepts a package_identifier parameter must validate it via a
validate_identifier() function before any use. The validation enforces:
- Character whitelist specific to the package manager's naming rules
- Length bounds (typically 2–128 characters)
- Path traversal rejection (
..segments) - First-character constraints (e.g., must start with a letter or digit)
See Plugin Guidelines — Package identifier validation for the implementation pattern.
Path traversal in plugin configs
Plugin config fields that represent filesystem paths (compose_file, working_dir,
project_dir) must reject .. path segments to prevent directory traversal. The Docker plugin's
DockerConfig::validate() enforces this for both compose_restart.compose_file and
compose_restart.working_dir.
Any new plugin config field that accepts a path must apply the same validation pattern:
if path.split('/').any(|seg| seg == "..") {
bail!(PluginError::Configuration("field must not contain '..' path segments".into()));
}
Version string validation
Plugins that interpolate a to_version parameter into install commands (e.g.,
npm install -g pkg@version, apt-get install pkg=version) must validate the version string.
Even though CommandSpec::exec() mode prevents shell injection, package managers have their
own argument parsing:
- npm: A version like
file:../maliciousorgit+https://attacker.comcould install attacker-controlled packages. - apt: A version like
1.0 --allow-unauthenticatedcould alter command behavior through flag injection.
Validation rules per plugin:
| Plugin | Allowed characters | Rejected patterns |
|---|---|---|
| npm | [a-zA-Z0-9._+-] | Empty, >256 chars, file:, git+, http:, https: prefixes |
| apt | [a-zA-Z0-9.+~:-] | Empty, >256 chars, leading - (flag injection) |
See Plugin Guidelines — Version string validation for the implementation pattern and testing requirements.
Atomic ordering for security flags
AtomicBool flags that control security-sensitive behavior (such as PLAINTEXT_MODE in
uptrakit-crypto) must use Ordering::Release for stores and Ordering::Acquire for loads.
See Coding Standards — Atomic Ordering Requirements.
Build metadata exposed by --version is intentionally non-secret (crate version, enabled build features, target/cfg/profile). Never include
credentials, tokens, private keys, or runtime secret material in any version/build output.
Freeze File Guard for Update Execution
Both uptrakit-agent and uptrakit-agent-ssh check for a freeze file at
<state-dir>/update-freeze before processing ExecuteUpdate or
ExecuteBatchUpdate messages. When the file exists, the message is
silently dropped and a tracing::warn! is emitted. This is an emergency stop
mechanism — not a per-command review gate.
The freeze file can be created in two ways:
- Locally:
touch <state-dir>/update-freezeon the agent host. - Remotely: The controller sends a
set_update_freezemessage (see Wire Protocol —set_update_freeze).
Any new ControllerMessage variant that triggers command execution on agents
must include the freeze file check in its handler. See:
crates/core/agent/src/main.rs— local agent freeze checkcrates/core/agent-ssh/src/main.rs— SSH agent freeze check
Agent-Side Execution Hardening
Per-hook plugin timeout
Individual pre/post-update lifecycle hook plugin executions have a 5-minute
timeout (HOOK_TIMEOUT = 300s), enforced via tokio::time::timeout(...)
around each hook trait call in agent-core (crates/shared/agent-core/src/update.rs).
On timeout, the underlying command executor's own cleanup guard kills the
hook's child process (kill_on_drop(true), the default AbandonmentPolicy).
A pre-hook timeout fails the update; a post-hook timeout is logged as a
warning and does not block the remaining hooks. This prevents a single
malicious or stuck hook from consuming the entire update timeout budget.
Update rate limiting
Both agents enforce an UPDATE_COOLDOWN of 5 seconds between consecutive
update executions. For the SSH agent, cooldown is tracked per-host. Updates
arriving within the cooldown window are rejected and emitted as
system.service.update_gate semantic audit events. This limits the damage
rate from a compromised controller while preserving an auditable gate
decision trail.
Hook plugin execution logging
Before executing pre/post-update lifecycle hook plugins, agents emit a
structured tracing::info! event listing the hook plugin count. This
provides operational visibility for hook execution flow.
Wire Protocol Payload Validation
Wire protocol payloads are validated after deserialization via the
WireValidate trait (crates/shared/wire/src/limits.rs). Per-collection and
per-string size limits prevent O(N) and O(N*M) processing attacks within the
1 MB WebSocket frame limit.
AuditEventPayload is an intentional exception: ServiceMessage::AuditEvent
is forwarded without wire-layer field validation so the controller can enforce
the canonical semantic-audit contract in one place
(ingest_service_audit_event / validate_audit_event_payload in
crates/ui/web-api/src/routes/service_ws/handler/mod.rs).
Any new wire protocol payload struct with Vec<T> or String fields must
implement WireValidate in crates/shared/wire/src/wire_validate_impls.rs
unless it is intentionally controller-validated like AuditEventPayload.
See Wire Protocol — Payload Size Limits for the full limits table.
Semantic Audit Logging for Privileged Operations
All mutations to command-bearing plugin configs are logged via structured
semantic audit entries (plugin_config.create, plugin_config.update,
plugin_config.delete) with allow/deny outcomes. This creates an observable
trail for operations that grant effective RCE on managed hosts.
See Coding Standards — Security Audit Logging for the implementation pattern and required fields.
Dangerous Command Pattern Rejection
Dangerous command pattern rejection is enabled by default. Plugin config
create/update requests containing known dangerous patterns are rejected with HTTP 400
before the database write. Operators who need to bypass this protection can use the
--allow-dangerous-commands CLI flag (or UPTRAKIT_ALLOW_DANGEROUS_COMMANDS
environment variable) to downgrade detection to advisory-only.
Detected patterns include:
- Pipe-to-shell (
curl|bash,wget|sh, includingsudo/env/doas/run0wrappers) - Destructive filesystem operations (
rm -rf /,dd if=,mkfs.) - Fork bombs (
:(){ :|:& };:) - Bash network sockets (
/dev/tcp/,/dev/udp/)
The detection logic lives in uptrakit-web-api-types::command_validation::detect_dangerous_patterns.
The rejection gate is in crates/ui/web-api/src/routes/plugin_configs.rs
(collect_dangerous_patterns, format_dangerous_pattern_rejection).
When disabled via --allow-dangerous-commands, detected patterns still appear in
semantic audit details for plugin config create/update outcomes. The underlying threat: an authenticated user holding the commands:manage
action can craft plugin configs (shell commands, Docker post_pull_command, or hook plugin
commands) that execute arbitrary code on managed hosts. Mitigations include action separation, dangerous
pattern rejection, command length limits, and semantic audit logging.
SSRF Protection
All reqwest::Client instances that send requests to user-controlled URLs must use the
SsrfSafeResolver custom DNS resolver to prevent DNS rebinding attacks.
The problem
is_private_host() validates hostnames at config-save time, but a DNS rebinding
attack can cause a hostname to resolve to a private IP at HTTP-request time.
For example, evil.com resolves to 1.2.3.4 during validation, then to
169.254.169.254 when the actual request is made.
The solution
SsrfSafeResolver (in uptrakit_shared_types::ssrf, feature http-ssrf) implements
reqwest::dns::Resolve and filters every resolved IP address through is_private_ip().
If all resolved addresses are private, the request fails. If there is a mix of public
and private addresses, only public addresses are returned.
Usage
use std::sync::Arc;
use uptrakit_shared_types::ssrf::SsrfSafeResolver;
// Standard mode: blocks private IPs
let client = reqwest::Client::builder()
.dns_resolver(Arc::new(SsrfSafeResolver::new()))
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(60))
.build()?;
// Permissive mode: allows all (for --allow-private-notification-urls)
let client = reqwest::Client::builder()
.dns_resolver(Arc::new(SsrfSafeResolver::permissive()))
.build()?;
reqwest::Client::builder() is clippy-banned outside the audited sites named in
ADR-0047. Plugin
clients build through build_plugin_http_client, which wires the resolver together
with a typed RedirectMode; the operator-context crates keep a direct builder behind
a scoped #[expect(clippy::disallowed_methods, …)].
Where it is applied
All plugin HTTP clients and the webhook notification channel use SsrfSafeResolver:
- Release plugins: GitHub, GitLab, Forgejo, Docker registry
- Package manager plugins: npm (including custom registries), cargo, uv
- Discovery plugins: Proxmox Helper Scripts
- Notification channels: Webhook (permissive when
--allow-private-notification-urls) - Agent-core: shared update logic (
crates/shared/agent-core)
The Docker plugin additionally validates the registry hostname in validate_identifier()
via is_private_host(), providing defence-in-depth at config-save time.
When to add it
Any new reqwest::Client that sends requests to URLs derived from user configuration
must use SsrfSafeResolver::new(). Add uptrakit-shared-types = { workspace = true, features = ["http-ssrf"] } to the crate's Cargo.toml.
Internal-only clients (e.g. the openapi-client connecting to a known controller URL, or the CA certificate fetch in service-sdk) do not need SSRF protection because their target URLs are not user-controlled.
Residual risk: initial-URL IP literals
SsrfSafeResolver filters addresses at DNS resolution time. An IP literal in the initial
URL (e.g. http://169.254.169.254/) never reaches the resolver, so the resolver guard does
not cover it. Redirect targets are covered separately: a RedirectMode::Limited client's
per-hop guard (check_hop, see
ADR-0047) rejects a
private-IP-literal hop under SsrfMode::Strict, independent of DNS resolution. Hardening
the initial-URL case — for example by classifying the URL host before the first connect —
is deferred.
Documented exception: operator-context CLI clients
The SSRF rule's threat model is server-side confused-deputy fetches and DNS rebinding: a
server process, acting on a URL supplied by one user, is tricked into reaching a host that
user could not otherwise reach. That model does not apply to the CLI's bootstrap clients
(fetch_ca in crates/ui/cli/src/commands/auth.rs), which run at the operator's own
network position with no server-side ambient authority — the request goes exactly where the
operator invoking the CLI could already reach directly.
SsrfSafeResolver would be either breaking or vacuous here: Strict mode blocks the
private-range addresses self-hosted controllers actually live on (and mDNS-discovered URLs
are usually IP literals a DNS resolver never even sees, so the rebinding attack the resolver
guards against cannot occur on this path), while permissive mode allows all resolved
addresses and adds no protection. The compensating control on the discovery path is the
mandatory interactive URL confirmation plus the fingerprint (TOFU) ceremony in
establish_ca_trust — the URL the client connects to is either operator-typed or
operator-confirmed before any request carrying credentials is sent.
This is a second, narrower exception alongside the "Internal-only clients" carve-out above,
and the two must not be conflated: the internal-only exception's rationale is
not-user-controlled (the openapi-client and service-sdk CA fetch target a URL the
process itself was configured with, never a value an end user supplied at request time); this
exception's rationale is operator-controlled-and-confirmed (the CLI's target URL is
user-supplied — typed or discovered — but the "user" is the same operator running the
process, at their own network vantage point, and the value is confirmed via the fingerprint
ceremony before use). Both end up skipping SsrfSafeResolver, but for different reasons; do
not cite one exception's rationale to justify a future case that only fits the other.
NATS Plugin Config Protection
Plugin configs published to NATS JetStream are encrypted with AES-256-GCM using the shared master key before publication. This prevents NATS subscribers (including compromised infrastructure) from reading API tokens, registry passwords, and other credentials embedded in plugin configurations.
Any new ControllerMessage variant that carries plugin config fields with
credentials must be added to the encrypt_message_configs() /
decrypt_message_configs() match arms in
crates/shared/nats/src/config_protection.rs.
See NATS Integration — Plugin Config Protection for the full mechanism.
TLS hot-swap idioms
Controller and Agent both rely on rustls 0.23 trait-object hot-swap
patterns:
rustls::client::ResolvesClientCert(Agent) — swap Agent cert without reconnecting the WebSocket.rustls::server::ResolvesServerCert(Controller) — swap server cert without rebuildingServerConfig.DynamicClientVerifier(Controller) — wrapsWebPkiClientVerifierbehindarc_swap::ArcSwap; CRL rebuilds and CA-bundle updates swap the verifier in place.
All three hold an arc_swap::ArcSwap<_> inner and are installed once
on the relevant Config at startup. See spec §5.4.