@git-stunts/git-warp is designed with security-by-default principles, treating the underlying Git binary as an untrusted subsystem through the @git-stunts/plumbing layer.
This library inherits all security protections from @git-stunts/plumbing:
- Command Sanitization: All Git commands are validated through a strict whitelist
- Argument Injection Prevention: Refs are validated against strict patterns to prevent command injection
- No Arbitrary Commands: Only safe Git plumbing commands are permitted
- Environment Isolation: Git processes run in a clean environment with minimal variables
The GitGraphAdapter validates all ref arguments to prevent injection attacks:
- Refs must match the pattern:
^[a-zA-Z0-9._/-]+((~\d*|\^\d*|\.\.[a-zA-Z0-9._/-]+)*)$ - Refs cannot start with
-or--to prevent option injection - Invalid refs throw an error immediately
- Streaming-First: Large graph traversals use async generators to prevent OOM
- Bitmap Indexing: Sharded Roaring Bitmap indexes enable O(1) lookups without loading entire graphs
- Bitmap Shard Validation: Every serialized Roaring bitmap is validated before the bitmap library reads it. The boundary refuses a stored value that is not a byte string, checks the portable format structure, container headers, offsets, and container contents, and rejects one bitmap above 34,086,920 bytes with
RoaringShardRejectedError. An alive bitmap (the live nodes of one meta shard) is also rejected above 2^24 values, which is all that one meta shard can address. An edge bitmap (the neighbours of one node) is bounded by bytes and by structure, not by cardinality, because neighbours come from every meta shard. No write is refused: the boundary checks a bitmap only when it is read. With the default CBOR codec, the codec refuses an index shard file above 5 MiB first, so the 34,086,920-byte bound cannot fire for a stored bitmap there. The byte bound applies to one bitmap. One index shard file holds many bitmaps, and memory still grows with the number of bitmaps in that file. An in-process read that expands a very large edge bitmap into an array is not bounded by this boundary; #1159 tracks it. A checkpoint neighbourhood page does bounded work: it visits at most 4 Γlimit+ 256 stored candidates and then ends with a cursor, whatever the edge bitmap holds. Theroaring-wasmrow under Accepted Risks is older than this boundary; #1032 owns that table and updates the row - Delimiter Safety: Uses ASCII Record Separator (
\x1E) to prevent message collision
The HTTP sync protocol supports optional HMAC-SHA256 request signing with replay protection. When enabled, every sync request must carry a valid signature computed over a canonical payload that includes the request body, timestamp, and a unique nonce.
Protected against:
- Unauthorized sync requests from unknown peers
- Replay attacks in enforce mode (signed freshness plus durable nonce reservations)
- Request body tampering (HMAC covers body SHA-256)
- Timing attacks on signature comparison (
timingSafeEqual)
Not protected against:
- Compromised shared secrets (rotate keys immediately if leaked)
- Denial-of-service from many socket addresses (body size limits and the peer admission limit bound the parse and authentication work that one socket address can cause; they do not bound the body read, and they do not bound a flood from many addresses)
- Man-in-the-middle without TLS (use HTTPS in production)
- Client computes SHA-256 of request body
- Client reads epoch milliseconds through its injected clock and builds the canonical payload:
warp-v3|shared-secret-hmac-sha256|KEY_ID|METHOD|PATH|TIMESTAMP|NONCE|CONTENT_TYPE|BODY_SHA256 - Client computes HMAC-SHA256 of canonical payload using shared secret
- Client sends six auth headers:
x-warp-auth-scheme,x-warp-sig-version,x-warp-key-id,x-warp-timestamp,x-warp-nonce,x-warp-signature - Server applies the peer admission limit to the socket peer, before it parses the body, and then validates header formats (cheap checks first)
- Server resolves the key by key-id, recomputes HMAC, and compares with constant-time equality
- Server checks the signed timestamp against its injected clock (inclusive five-minute skew allowance)
- Server reserves the nonce atomically and durably (prevents replay)
- Server checks freshness again after reservation completes, on the replay store's time and on its injected clock; enforce mode refuses an expired request and its nonce remains reserved
- Server applies the configured per-key rate limit before graph work
enforce(default): Rejects requests that fail authentication with appropriate HTTP status codes (400/401/403). No request details leak in error responses.log-only: Logs authentication failures but allows requests through. Use during rollout to identify issues before enforcing. Retired signature versions are still refused before authentication.
External error responses use coarse status codes and generic reason strings:
400β Malformed headers (version, timestamp, nonce, signature format)401β Missing auth headers, unknown key-id, invalid signature403β Expired timestamp, replayed nonce429βPEER_RATE_LIMITED(the socket peer is over its admission limit; the response has aRetry-Afterheader) orRATE_LIMITED(the key-id is over its configured limit)
Detailed authentication diagnostics (exact failure reason, key-id) are sent to the structured logger only. A peer admission refusal logs the peer address and the Retry-After value to the server's structured logger, when the runtime has a logger; see Peer Admission Limit.
Signature v3 binds an epoch-millisecond wall-clock timestamp and nonce to the
request HMAC. The verifier uses an injected clock and accepts a timestamp only
when its distance from the server clock is at most 300,000 ms (five minutes),
in either direction. A timestamp outside this inclusive interval returns
403 EXPIRED. Signature v2 used a Lamport counter and is refused with
400 SIG_VERSION_RETIRED before graph work in every mode, including log-only
and explicit unauthenticated localhost operation.
The Git runtime reserves each graph/key-id/nonce identity durably through git-cas before admitting the request. Reservations use a 600,001 ms retention interval, with expiry calculated from the clock snapshot at reservation entry. A request accepted five minutes early can remain valid for ten minutes, and the extra millisecond keeps its nonce reserved at the inclusive freshness boundary. git-cas preserves millisecond expiry and treats a marker as expired when its expiry is at or before the current time.
The runtime's ClockPort signs requests and gives the early refusal. The final
freshness verdict and nonce retention both use the replay store's own time,
which is host time in the Git runtime. Each reservation reports two readings of
store time: one on entry, which sets the marker expiry, and one after the store
answers. In enforce mode the server admits a reserved request only when its
signed timestamp is at most 300,000 ms after the entry reading and at most
300,000 ms before the completion reading, both inclusive. Otherwise it returns
403 EXPIRED and the nonce stays reserved. A marker expires 600,001 ms after
its entry reading; from then on the completion bound refuses every timestamp
that the entry bound accepted. A caller-supplied runtime clock therefore cannot
reopen the replay window. A request must be fresh on both clocks, so a runtime
clock that disagrees with store time can refuse valid requests.
In enforce mode, a server restart preserves nonce refusal during the 600,001 ms
retention interval. Once the
reservation expires and is swept, the signed timestamp is already outside the
accepted interval. The server checks freshness again after the asynchronous
reservation. In enforce mode, crossing expiry while storage is awaited returns
403 EXPIRED before graph work. A nonce reserved during that wait remains reserved
on refusal.
Storage failure returns 503 REPLAY_STORE_UNAVAILABLE. Expired markers can be
swept; no permanent nonce history is required.
Keep host clocks synchronized. These guarantees assume clocks progress normally;
they do not provide protection against arbitrary wall-clock rollback. Local
log-only and explicit unauthenticated modes deliberately permit unauthenticated
traffic; they still refuse retired signature v2. Use enforce mode and TLS for
protected sync. See the
v21 sync authentication migration
before upgrading peers.
The sync server limits the request rate of each socket peer before it parses
the request body and before it does authentication work. The limit is on by
default. It applies in enforce mode, in log-only mode, and on an explicitly
unauthenticated localhost server. The per-key auth.rateLimit is a separate
limit and is unchanged.
- Key. The key is the peer address of the connected socket, as the runtime
reports it:
socket.remoteAddresson Node,server.requestIP()on Bun, andinfo.remoteAddron Deno. The server does not readX-Forwarded-For,Forwarded,X-Real-IP, or any other header to find the peer. A customHttpServerPortadapter must setHttpRequest.peerAddressfrom the connected socket and never from a header. - Address forms. The server does not parse or normalize the address text.
203.0.113.10and::ffff:203.0.113.10are two keys. A host that connects over IPv4 and over IPv6 has two budgets. - No peer address. Requests for which the transport reports no peer address share one budget. That budget counts toward the peer table bound.
- Order. The content type, route, method, retired signature version, and body size checks run first. A request that they refuse gets their response and spends no peer budget. Each request that passes them spends one token, whether authentication later accepts the request or not. A refused request causes no JSON parse, no SHA-256, no HMAC, and no nonce reservation.
- Refusal. The server returns
429with the body{"error":"PEER_RATE_LIMITED"}and aRetry-Afterheader. The header gives the whole seconds until the peer's budget holds one token again, calculated from the server's injected clock. - Default. Each peer has a burst of 60 requests and a refill of 10 requests per second. The server tracks at most 4096 peers.
- Configuration. Set
peerRateLimit: { capacity, refillTokensPerSecond, maxTrackedPeers }in the options ofserve()or ofHttpSyncServer.capacityandmaxTrackedPeersmust be integers from 1 to 1,000,000.refillTokensPerSecondmust be a finite number of at least 1/86,400, soRetry-Afteris never more than 86,400 seconds. The server refuses a value outside these ranges and refuses any other key. There is no switch that disables the limit. - Trace. When the runtime has a logger, each refusal logs one warning,
sync server: peer rate limit exceeded, with the fieldspeer(the peer address, ornullfor the shared no-address budget) andretryAfterSeconds. Without a logger, nothing is logged. The server logs every refusal, so a flood of refused requests gives one warning for each request. The first request without a peer address that passes the earlier checks also logs one warning for each server instance. A caller that constructsHttpSyncServerdirectly can read the refusal count asHttpSyncServer.getMetrics().peerRateLimitRejects;serve()returns only{ url, close }and exposes no metrics. - Peer table. When a new peer makes the table larger than
maxTrackedPeers, the server evicts the peer that it saw least recently. A refused request counts as recent use. Thus a peer that continues to send keeps its spent budget, unless at leastmaxTrackedPeersother addresses send between two of its requests. An evicted peer starts again with a full burst. - Time. The limiter reads time only from the server's
clock(ClockPort).serve()passes the runtime's clock; a caller that constructsHttpSyncServerdirectly supplies one.
The default comes from the measured cost of one rejected request. The
measurement is test/benchmark/SyncRejectionCost.benchmark.ts, run in the
Docker test image (Node.js 22.23.4, linux arm64) against the server's request
handler in process, without a socket. The figures do not include reading the
body from the socket, which every request costs, refused or not, and the
refused-request figure does not include writing the log line, because the
benchmark server has no logger. Each row has 5 warm-up requests and then 300 measured
requests (40 for the 4 MiB body). The values are wall-clock milliseconds as
p50 / p99.
| Rejected request | 37-byte body | 64 KiB body | 4 MiB body (default maximum) |
|---|---|---|---|
Malformed JSON (400) |
0.005 / 0.010 | 0.092 / 0.893 | 6.4 / 7.7 |
Unknown key-id (401) |
0.006 / 0.016 | 0.091 / 0.261 | 8.3 / 10.6 |
Known key-id, bad signature (401) |
0.011 / 0.017 | 0.117 / 0.338 | 9.9 / 18.1 |
Refused by the peer limit (429) |
0.002 / 0.011 | 0.002 / 0.005 | 0.002 / 0.004 |
At the default refill of 10 requests per second, one peer that sends rejected
4 MiB requests without pause uses approximately 10% of one CPU core at the p50
cost and 18% at the p99 cost. One burst of 60 such requests uses approximately
0.6 to 1.1 seconds of CPU time. One syncWith call sends one request for each
attempt, so the default admits ten clients behind one address that each sync
one time per second. The numbers are from one machine; measure again on your
hardware before you change the default.
The limit does not do these things:
- It does not separate clients that share a socket address. Clients behind one NAT gateway or one reverse proxy share one budget, and one of them can use all of it. The per-key limit does not make a shared address fair.
- It does not replace a rate limit at a reverse proxy. Behind a reverse proxy,
every request has the address of the proxy. Limit the rate at the proxy, and
set
peerRateLimitlarge enough for the total traffic of the proxy. - It is not a distributed limiter. Each server process has its own peer table, and a restart clears the table.
- It is not a global flood bound. Each address has its own budget. An
attacker that sends from more distinct addresses than
maxTrackedPeers(one host with an IPv6 prefix can) is not limited by this gate, because the table evicts each address before that address sends again. - It does not bound the read of the request body. The Node, Bun and Deno
adapters read each request body, up to their fixed limit of 10 MiB
(
MAX_BODY_BYTES, 10,485,760 bytes), before the limiter sees the request. A body larger than the server'smaxRequestBytes(4 MiB by default) is read in full, answered413, and spends no peer token.
The "No rate limiting on sync endpoint" row under Accepted Risks and the Availability row under Security Boundaries are older than this limit; #1032 updates them.
Key management uses a key-id system for zero-downtime rotation:
- Add the new key-id and secret to the server's
keysmap - Deploy the server
- Update clients to use the new key-id
- Remove the old key-id from the server's
keysmap - Deploy again
Multiple key-ids can coexist indefinitely.
Server (serve()):
await graph.serve({
port: 3000,
httpPort: new NodeHttpAdapter(),
auth: {
keys: { default: SyncSecret.fromString('your-shared-secret') },
mode: 'enforce', // or 'log-only'
},
});Client (syncWith()):
await graph.syncWith('http://peer:3000', {
auth: {
secret: SyncSecret.fromString('your-shared-secret'),
keyId: 'default',
},
});| Package | Type | Risk | Notes |
|---|---|---|---|
@flyingrobots/bijou |
CLI | Low | Terminal presentation primitives; graph-derived strings require terminal-safe rendering |
@flyingrobots/bijou-node |
CLI | Medium | Node terminal and I/O adapters used by CLI presentation |
@flyingrobots/bijou-tui |
CLI | Medium | Interactive terminal UI; handles user input and graph-derived presentation data |
@git-stunts/git-cas |
Runtime | Medium | Git content-addressed storage, caches, and index pages; persisted data crosses a decoding and validation boundary |
@noble/hashes |
Runtime | Medium | Hash primitives used for content identity; correctness is security-sensitive |
@git-stunts/plumbing |
Runtime | Low | Internal package, spawns git processes with strict whitelist |
@git-stunts/alfred |
Runtime | Low | Retry/backoff utility, no I/O |
@git-stunts/trailer-codec |
Runtime | Low | Pure string encoding, no I/O |
cbor-x |
Runtime | Medium | Binary parser processing untrusted sync payloads; mitigated by body size limit in HttpSyncServer (4 MB default) |
roaring-wasm |
Runtime | Low-Medium | WebAssembly build of CRoaring. Synced peer-controlled graph data influences bitmap construction, and retained index shards are deserialized by bitmap readers. WASM memory isolation does not establish input trust or prevent wrong results, traps, or resource exhaustion. git-warp validates and bounds each serialized bitmap before the library reads it (see Bitmap Shard Validation) |
zod |
Runtime | Low | Schema validation, pure JS |
| Risk | Severity | Owner | Expiry | Mitigation |
|---|---|---|---|---|
roaring-wasm bitmap faults could corrupt results or exhaust resources |
Medium | @flyingrobots | EXPIRED 2026-08-01 β pending re-review | WASM memory isolation limits host-memory exposure but does not validate peer-controlled graph data or retained bitmap shards; correctness and availability remain in scope. Input validation and resource bounds require owner re-review |
cbor-x parser handles untrusted sync payloads |
Medium | @flyingrobots | EXPIRED 2026-08-01 β pending re-review | 4 MB body size limit in HttpSyncServer; schema validation post-parse |
| Nonce cache lost on restart allows replay within clock skew window | Low | @flyingrobots | EXPIRED 2026-08-01 β pending re-review | 5-minute TTL; recommend TLS in production |
| No rate limiting on sync endpoint | Low | @flyingrobots | EXPIRED 2026-08-01 β pending re-review | Deploy behind reverse proxy with rate limiting |
| Category | In Scope | Out of Scope |
|---|---|---|
| Data integrity | CRDT convergence, CAS ref updates, patch ordering | Filesystem corruption, disk failure |
| Authentication | HMAC-SHA256 sync auth, key rotation, replay protection | User identity management, OAuth/OIDC |
| Authorization | None for sync: any valid sync key reads every writer's patches, and the sync server accepts no writes. allowedWriters only refuses a requester that reports an unlisted writer (see Writer Authorization) |
Role-based access control, per-node ACLs, per-writer read or write limits |
| Transport | Body signing, tamper detection | TLS termination (delegated to reverse proxy) |
| Availability | Body size limits, streaming for large graphs | DDoS protection, rate limiting (delegated to infrastructure) |
| Supply chain | npm audit in CI, pinned dependencies via lockfile |
Runtime SCA scanning, SBOM generation |
The built-in sync server supports an optional writer list, allowedWriters. A sync
request carries the frontier of the requester (writer ID to tip SHA), not patches; the
server sends patches in its response and does not apply patches from a request. The
server refuses a request whose frontier names a writer ID that is not in the list.
The list does not limit what the server sends. A peer with a valid key that sends an empty frontier, or leaves unlisted writers out of its frontier, receives the patches of every writer, listed or not. The list is therefore not an access control for reads or for writes. To limit who can read a graph, limit who holds its sync keys.
No package entry point exposes serve() in v19.0.0, v20.0.0 or v21.0.0.
await graph.serve({
port: 3000,
httpPort: new NodeHttpAdapter(),
auth: {
keys: { default: SyncSecret.fromString('your-shared-secret') },
mode: 'enforce',
},
allowedWriters: ['alice', 'bob', 'node-1'],
});- Writer ID matching is case-sensitive. Each listed ID must match
[A-Za-z0-9._-]+, have 1β64 characters and not contain.. - A non-empty list requires
auth; without itserve()refuses to start. Withauth,serve()refuses to start if a listed ID is invalid (E_INVALID_WRITER_ID) or the list is empty (E_SYNC_AUTH_EMPTY_ALLOWED_WRITERS) - When
allowedWritersis not set (default), the frontier is not checked - When set, a request whose frontier names an unlisted writer receives HTTP 403
(
FORBIDDEN_WRITER) inenforcemode. Inlog-onlymode the server answers the request and, if the runtime has a logger, logs a warning. Without a logger, and becauseserve()does not expose the metric,log-onlygives no signal - A request with an empty frontier names no writer, so this check passes it
- The sync auth service counts each frontier that fails the check in its
forbiddenWriterRejectsmetric, in both modes.serve()does not expose this metric - Before v21.0.0,
serve()did not passallowedWritersto the server, so the list had no effect (#1163)
If you discover a security vulnerability, please send an e-mail to james@flyingrobots.dev.