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_check on (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_check disabled, the access control falls back to the socket file's permissions alone, so the trusted set becomes any OS user who can connect(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_bind without 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 by max_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:

ThreatDefense
Local producer running as a different uidDefault-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 DoSStrict 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 payloadsmax_payload_bytes config cap (≤ MAX_PAYLOAD_HARD_CAP = 16 MiB) checked before allocation.
A batch frame declaring more records than it carriesmax_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 batchAckBatch 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 floodmax_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 clientsconnection_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 saturationQUEUE_PUSH_TIMEOUT (5s) caps the time a connection blocks waiting for a queue slot. Excess is nacked, not held.
Socket bind path TOCTOUbind_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 filesSegment 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 filesDetected 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 shutdownSIGTERM 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 fallbackSetting 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 scopeWhy / 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 authenticationPossession of socket open access = full producer authority. Filesystem permissions are the access control.
Privilege dropDaemon 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 / seccompNot applied. Operators should sandbox the binary externally if needed (systemd CapabilityBoundingSet=, RestrictAddressFamilies=AF_UNIX, PrivateTmp=yes, ProtectSystem=strict, etc.).
Encrypted at-rest WABWAB segments are plaintext on disk. Use filesystem-level encryption (LUKS / dm-crypt) if payloads are sensitive.
Non-repudiation / signed audit logThe 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 authorizationSinks (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 pathTwo daemon instances pointed at the same socket_path will race. Operator's responsibility (one daemon per socket).
Resource accounting per-clientAll 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:

  1. Created wab_dir with 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.
  2. 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 (see docs/security/socket-bind.md). If the parent is world-writable, the bind hardening's late-swap check becomes defeatable by a sufficiently fast attacker.
  3. Launched the daemon as a non-root user, ideally a dedicated weir system user. The daemon does not drop privileges; whatever uid starts it is the uid it serves under.
  4. 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_hardened make most of these refuse to start rather than silently accept; the rest (e.g. loose umask) are defended against by the startup umask(0o077) and bind-time umask(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.