Weir threat model
This document describes the trust boundaries, threat model, design assumptions, and explicit non-goals of the weir daemon. It is the place the operator should look first when deploying weir, and the place contributors should consult before adding a new attack surface.
Trust boundaries
Unix socket path
Weir's access control for local producers has two layers. The first is the
Unix domain socket file's permissions: the daemon binds the socket at
socket_path with mode 0o600. The second — and the primary
application-level control — is the default-on peer-uid check
(peer_uid_check, default true): on every accept the daemon reads the
peer's effective uid (SO_PEERCRED on Linux, getpeereid on macOS) and
refuses any connection whose peer euid does not match the daemon's own,
closing the socket before a single frame is read. It is fail-closed — if
the credential lookup fails, the connection is refused — and each refusal
increments weir_connection_rejected_peer_uid_total.
This means:
- With
peer_uid_checkon (the default), the set of clients trusted to push records = exactly the single OS uid equal to the daemon's effective uid. Root is not implicitly trusted: a process running as root cannot push to a daemon running as a non-root user, because root's euid (0) does not match the daemon's. (If the daemon itself runs as root, then root is the trusted uid — but running as root is discouraged; see the assumptions below.) - With
peer_uid_checkdisabled, the access control falls back to the socket file's permissions alone, so the trusted set becomes any OS user who canconnect(2)to the socket path. - A separate daemon-group model is not currently supported. If you need group-readable access, set the parent directory mode appropriately and the daemon socket mode to 0o660 in a future config option (not yet implemented).
- Beyond the peer-uid check there is no per-client application-level authentication on the Unix path: a connection that passes the uid check is treated as fully authorised, and payloads are opaque bytes.
TCP + mutual TLS path (optional, --features tls)
When tcp_bind is configured, remote producers connect over mandatory
mutual TLS (rustls, ring provider). The trust model differs from the
Unix path:
- Client cert required. Every TCP client must present a certificate
signed by the configured CA (
tls_client_ca_path). Anonymous or cert-less clients are rejected at the TLS handshake. - Trust model: CA issuance. Issuing a client certificate from the configured CA is the act of authorising a producer. There is no per-CN/SAN allowlist — CA-signed cert = authorised producer.
- Plaintext TCP is never exposed. Setting
tcp_bindwithout a valid TLS configuration is a fatal startup error; weir never downgrades to cleartext. - Shared connection cap. The Unix and TCP listeners draw from one shared
semaphore (
max_connections); the total concurrent connections across both transports is bounded bymax_connections, not 2×. - Handshake-slowloris bounded.
tls_handshake_timeout_secs(default 10s) caps how long a TCP client may stall during the handshake while holding a connection permit. - Revocation by CA rotation. CRL and OCSP are out of scope (v2). To
revoke a client cert, rotate the CA: issue a new CA, re-issue all certs
from it, update
tls_client_ca_path, send SIGHUP. The old CA is immediately distrusted.
See TCP + mutual TLS for the operator guide.
In scope
Threats the daemon defends against:
| Threat | Defense |
|---|---|
| Local producer running as a different uid | Default-on peer-uid check (peer_uid_check): the accept loop refuses any connection whose peer euid (SO_PEERCRED/getpeereid) does not match the daemon's, fail-closed, before any frame is read. Increments weir_connection_rejected_peer_uid_total. The application-level complement to the socket's 0o600 mode. |
| Malformed wire frames causing OOM or DoS | Strict validation order: magic → version → header CRC → header fields (message_type/durability/flags) → payload cap → allocation → payload CRC. No allocation occurs before bounds checks. See crates/weir-core/src/envelope.rs and crates/weir-server/src/socket/connection.rs. |
| Oversized payloads | max_payload_bytes config cap (≤ MAX_PAYLOAD_HARD_CAP = 16 MiB) checked before allocation. |
| A batch frame declaring more records than it carries | max_batch_records config cap (≤ MAX_BATCH_RECORDS_HARD_CAP = 2048) checked against the declared count before anything is sized by it, and each record's declared length checked against the per-record cap before it is added to the cursor. record_count is a u16, so without the cap a ~320 KiB frame could declare 65,535 records — 99.998% of the daemon's global queue capacity, all of it aimed at the single partition the connection is pinned to. The payload CRC has already passed by the time the body is parsed and contributes nothing here: a hostile peer computes a perfectly valid CRC over a body declaring 65,535 records in three bytes. Two conformance vectors carry no record data at all, precisely so that reversing this check order fails them. |
| A batch reply that answers a different batch | AckBatch echoes the record count, and a client must check it against what it sent. ceil(N/8) is not injective — N of 1017 through 1024 all yield 128 bitmap bytes, a 131-byte payload — so length alone cannot detect a desync, and a bitmap carries no other self-identifying field. |
| Connection flood | max_connections semaphore (default 256, max 512). Connections beyond cap are dropped immediately. |
| Aggregate memory amplification (many large payloads at once) | Each in-flight payload buffer starts as a bounded first slice (PAYLOAD_PREALLOC_CAP = 64 KiB); the remainder of the client-declared length is reserved only after the client has actually delivered that first slice (fixed in 2.0.3 — before, the full declared payload_len was allocated before a single body byte arrived, so a client could reserve up to max_payload_bytes per connection for free just by lying in the header). Concurrency is bounded by max_connections, so worst-case transient receive memory is max_connections × max_payload_bytes, but reaching it now requires genuinely streaming that much data — the free-of-data-transfer floor is only max_connections × PAYLOAD_PREALLOC_CAP (≈ 16 MiB with the 256-connection default). With the defaults (256 × 16 MiB) the streamed worst case is still ≈ 4 GiB if every connection simultaneously streams a max-size payload. There is no global byte budget across connections in 1.0 — size max_connections and max_payload_bytes for the host's RAM, and lower max_payload_bytes (it caps individual records) if you do not need 16 MiB records. A cross-connection memory budget with admission control is tracked for a post-1.0 release. |
| Slowloris / stalled clients | connection_read_timeout_secs (default 30s, range 1–600) wraps every read_exact in tokio::time::timeout. Idle connections are dropped and the counter weir_connection_idle_timeout_total increments. |
| Queue saturation | QUEUE_PUSH_TIMEOUT (5s) caps the time a connection blocks waiting for a queue slot. Excess is nacked, not held. |
| Socket bind path TOCTOU | bind_hardened (see docs/security/socket-bind.md) uses dirfd + AT_SYMLINK_NOFOLLOW + umask-tightened bind to eliminate the post-bind chmod vulnerability. |
| Symlink attacks on WAB files | Segment creation uses O_CREAT | O_EXCL | O_NOFOLLOW with explicit mode 0o600. Recovery uses O_NOFOLLOW on both passes — the read/validation open and the truncate/seal open — so a symlink swapped in at the segment path is refused in either case. |
| Stale socket files | Detected and replaced atomically; refuses to remove anything that isn't a socket. |
| WAB segment tampering (mode tightening) | audit_segment_modes runs at startup and warns on any .wab/.wab.sealed/.wab.confirmed file whose permissions are not 0o600. Increments weir_wab_unexpected_mode_total. |
| Bad CRCs (header or payload) | Reject with Nack(BadHeaderCrc | BadPayloadCrc) and close the connection; counter incremented. |
| Stuck pipeline on shutdown | SIGTERM handler unwinds the pipeline in dependency order; the runtime is dropped before joining threads so background tasks holding queue sender clones don't deadlock the join. |
Unauthenticated remote TCP producers (--features tls) | Mutual TLS with CA-signed client cert required. Anonymous clients rejected at handshake; increments weir_tls_handshake_failures_total{reason="no_client_cert"}. |
TLS-handshake slowloris on TCP path (--features tls) | tls_handshake_timeout_secs (default 10s) bounds handshake duration. The connection cap semaphore permit is held across the handshake, so a flood of stalled TCP connections is bounded by max_connections. Increments weir_tls_handshake_failures_total{reason="timeout"}. |
| Plaintext TCP fallback | Setting tcp_bind without a valid TLS config is a fatal startup error. The daemon never opens a plaintext TCP socket. |
Tamper evidence — operator-run, not a daemon defense
This is not a threat the daemon defends against. Everything in the table above runs inside the daemon process, unattended. What follows is a separate tool the operator must schedule and anchor themselves — it is listed on its own so it is never mistaken for one of the guarantees above.
weir-attest hash-chains every sealed WAB segment (weir-ctl attest seal|verify|head) over each record's already-frozen RecordId, linked to
the previous segment's chain head. Catches: offline tampering (a segment
copied to backup, archive, or another host and edited there), a different UID
on the same host, an edit made on a shared or network filesystem where
weir-wab's own guarantees are already documented not to hold, and silent
corruption that happens to pass CRC32 (a 32-bit checksum recomputes cleanly
in microseconds after an edit; a SHA-256 chain does not). The honest one-line
description is "prove a sealed buffer was not edited after the fact" — and it
holds only if the operator has actually wired up the cron/timer and the
off-host log shipping described in
monitoring.md. A .attest
sidecar nobody ever ran verify against, or whose head was never observed
off-host, is not evidence of anything — it is inert until an operator makes
it otherwise.
Out of scope (current non-goals)
Things weir does not defend against in the current version. Some are deliberate architectural choices; others are future work.
| Out of scope | Why / what to do instead |
|---|---|
| Remote producers (unauthenticated) | TCP listener requires mutual TLS (CA-signed client cert). Anonymous TCP connections are rejected at the handshake. Plaintext TCP is never exposed. See TCP + mutual TLS. |
| Per-client authentication | Possession of socket open access = full producer authority. Filesystem permissions are the access control. |
| Privilege drop | Daemon runs as whatever user invoked it. There is no built-in setuid/setgid to a dedicated weir user. Operators are expected to launch under the desired user (e.g. via systemd User=weir). |
| Linux capabilities / seccomp | Not applied. Operators should sandbox the binary externally if needed (systemd CapabilityBoundingSet=, RestrictAddressFamilies=AF_UNIX, PrivateTmp=yes, ProtectSystem=strict, etc.). |
| Encrypted at-rest WAB | WAB segments are plaintext on disk. Use filesystem-level encryption (LUKS / dm-crypt) if payloads are sensitive. |
| Non-repudiation / signed audit log | The hash chain described above ("Tamper evidence — operator-run, not a daemon defense") is not a signature. It proves internal consistency and, via an off-host anchor, consistency with what was already observed — it proves nothing about who wrote a record, and signing remains a possible follow-up, not something shipped. It also does not defend against a process running as the daemon UID: that is already out of scope above and unchanged here — such a process can rewrite the segment, the .attest sidecar, and the anchor's own log line in one motion. Detection only, never prevention: nothing stops the write, and a chain whose head was never observed off-host is not evidence of anything. |
| Sink-side authorization | Sinks (HTTP / MySQL / Postgres / ClickHouse / S3, plus the built-in noop) carry their own credentials to the downstream system; weir does not pass through producer identity. Authorization to the downstream is the sink's / operator's responsibility. The S3 sink additionally carries its own hand-rolled SigV4 signer and resolves credentials from static config, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, IRSA or an EC2 instance role (crates/weir-sink-s3/src/creds.rs); the workspace is deliberately ring-only and CI fails if aws-lc-sys enters the tree. |
| Concurrent-write protection on socket path | Two daemon instances pointed at the same socket_path will race. Operator's responsibility (one daemon per socket). |
| Resource accounting per-client | All connections share the same max_connections budget. No per-uid quotas. A misbehaving client can consume up to the global cap. |
Operator-side assumptions
The hardening above assumes the operator has:
- Created
wab_dirwith appropriate ownership and mode 0o700. The daemon does not create the WAB directory (PostgreSQL model). It does validate at startup that the directory exists and is canonicalisable. - Created the parent of
socket_path(e.g./run/weir/) writable only by the daemon's user. This closes the residual race window in the bind sequence (seedocs/security/socket-bind.md). If the parent is world-writable, the bind hardening's late-swap check becomes defeatable by a sufficiently fast attacker. - Launched the daemon as a non-root user, ideally a dedicated
weirsystem user. The daemon does not drop privileges; whatever uid starts it is the uid it serves under. - Tightened the process sandbox externally if required: systemd
unit options, AppArmor / SELinux profiles, container
--cap-drop, etc.
Threat actors considered
- Local unprivileged user (different uid): blocked from connecting by socket file mode 0o600. Cannot read the WAB (parent dir 0o700). Cannot win the bind-time TOCTOU window unless the socket's parent directory is writable.
- Operator-misconfigured environment (e.g. loose umask, world-
writable parent, stale socket file owned by another user): the
daemon's startup checks and
bind_hardenedmake most of these refuse to start rather than silently accept; the rest (e.g. loose umask) are defended against by the startupumask(0o077)and bind-timeumask(0o177). - Buggy or malicious producer connected to the socket: bounded by per-connection caps (payload size, read timeout) and global caps (max_connections, queue capacity). Cannot exhaust memory or block the daemon indefinitely; can only consume their share of the connection budget.
- Compromised process running as the daemon uid: out of scope. Equivalent to the daemon itself. Defense is OS-level isolation.
Reporting
Found something? Please open a private security advisory on the repository, or contact the maintainer directly. Do not file a public issue for a verified vulnerability before the fix has shipped.