← all reviews

High

forumer

https://github.com/bristinWild/forumer @ b9907523bffc
3 high5 medium5 low
Reviewed2026-10-28 08:02 UTC
Modeldeepseek-v4-1-flash-1017
Cost1.20 DIEM
Package5bfb8c3b1e4ce2ed…

Bottom line

The cryptographic core is the strongest part of this module. Posts are Ed25519-signed, content-addressed, proof-of-work checked, size-bounded and rejected on any canonical-field change; private-forum payloads are XChaCha20-Poly1305 sealed on the wire and at rest; vaults use Argon2id + XChaCha20-Poly1305; and all 163 unit tests pass, including tampering, replay/placement, media-ownership and flood-limit tests. I did not find a way to forge, alter or splice a post, nor to read a private forum without its key.

The most serious weakness is a flood-control bypass on the history/store path: posts timestamped in the window the node is currently walking skip both the new-key budget and the live arrival limit, so a peer that sees a range request can inject fresh-key spam at proof-of-work speed (FR-11). The unauthenticated sync surface is the next risk: two maps used to suppress duplicate media hints and previews are written from every mhere / mprev message before any signature or budget check, and are never pruned, so a peer can make the backend allocate memory without paying proof-of-work (FR-1). The same unauthenticated media-hint path passes attacker-chosen peer ids and multiaddrs to storage_module.connect(), so the local storage node can be made to dial loopback/private addresses (FR-2). Media-ask replies are also a cheap amplification channel (FR-3). Separately, the private-forum index columns in the local SQLite log stay in clear while the envelope is sealed, leaking forum membership/persona/timestamp metadata to anyone who can read the unlocked device's data folder (FR-4).

The QML side is generally careful: every Text element sets PlainText, MdText escapes HTML/images, links are restricted to http(s), and no QML path loads remote images directly. The exceptions are diagnostic logs that print full post ids/CIDs (FR-6) and backend logs that still contain 16-character forum ids, fingerprints and filesystem paths despite the A5 claim (FR-13), plus an empty-input nullptr passed to libsodium (FR-7), all confirmed here. Preview sealing also reuses an AEAD nonce when two clients seal different previews of the same image (FR-12). The top-level build could not be reproduced: it requires the Nix logos-module-builder; the core library was built and probed standalone.

This is triage, not approval. It does not certify the module as safe.

What the module did

Nothing flagged: no bait read or sent, no unexpected traffic.

Findings (13)

High FR-11 History/store import path bypasses the live flood budgetsrun · src/forumer_backend.cpp:3275-3310 (checkPost), src/forumer_backend.cpp:3321-3395 (handlePost/inHistoryWindow), src/forumer_backend.cpp:3361-3372 (new-key budget skipped), lib/forumer_core/src/flood.h

Files: src/forumer_backend.cpp:3275-3310 (checkPost), src/forumer_backend.cpp:3321-3395 (handlePost/inHistoryWindow), src/forumer_backend.cpp:3361-3372 (new-key budget skipped when inHistoryWindow), lib/forumer_core/src/flood.h layers 2-3.

Exploit: handlePost uses one predicate for both the arrival limit and the new-key budget:

const bool inHistoryWindow =
    m_storeImport || (m_historyActive && ts >= m_historyFrom && ts < m_historyTo);
const std::string problem = checkPost(post, inHistoryWindow);
...
if (!inHistoryWindow && !m_posts->hasAuthor(post.publicKey)) {
    // new-key / priority token buckets
}

With fromHistory=true, checkPost skips countByAuthorArrival (the live anti-backdating limit) and only checks the per-key daily cap, counted over the author's own [ts-24h, ts]. The new-key budget is also skipped. A peer can read the unencrypted range request ({"v":1,"t":"range","since":…,"until":…}) that the victim publishes on the public topic, then send posts timestamped inside that [since,until) window. Each post is signed by a fresh key, so the per-key caps never bind; only 16-bit PoW per post is paid. The probe probes-review/p_history_flood.cpp (rules replicated against the real PostStore/flood/post libraries) offers 45 such posts spaced 30 h apart in a witnessed 90-day window:

posts offered in the witnessed history window: 45
accepted by the history-path rules: 45
rejected: 0
of the accepted, refused three-per-minute-ish by the new-key budget
if they had come in live: 45 (skipped on this path)
stored rows: 45

The same predicate makes any message returned by a store node (m_storeImport) bypass both budgets; the store fleet is remote and can be compromised or spoofed on-path.

Fix: Do not skip the new-key budget or per-arrival limit for history/store posts. Add a separate bounded history-import rate (e.g. tokens per minute and a byte budget) and apply the same arrival clock; cap the number of posts accepted per range/store page per peer; validate that history posts are actually in a range we requested from a peer/store we asked.

Medium FR-12 Preview AEAD key/nonce reuse for different previews of the same imagerun · lib/forumer_core/src/media.cpp (derive(), sealPreview())

Files: lib/forumer_core/src/media.cpp (derive() computes nonce = BLAKE2b(kNonceLabel || key); sealPreview() passes hexBytes(full.sha256) as the derivation input).

Exploit: The XChaCha20-Poly1305 nonce is a deterministic function of the full image's signed SHA-256, not of the preview bytes. Two clients (or two encoders) sealing different preview JPEGs for the same full image use the same key and the same nonce. probes-review/p_preview_nonce.cpp confirms keystream equality and recovers preview 2 from preview 1 plus both blobs without any key:

sealed preview 1: 320 bytes, preview 2: 320 bytes
ciphertext XOR == plaintext XOR (same key+nonce keystream): YES -> nonce reuse
both open: 1 1
p2 recovered from p1 and both sealed blobs: YES

Impact is bounded because the blob is also signed together with postId:index in acceptPreview, and public-forum preview keys are derivable from the signed full-image hash; the practical break is cross-preview confidentiality/forgery inside a key domain where the attacker often already knows one plaintext. It is still nonce reuse in an AEAD and should be fixed before previews are treated as a security boundary.

Fix: derive the preview key/nonce from the preview blob (or a random per-seal nonce stored with the blob), not from the full image alone; bind postId/index into the associated data. Add a test that different previews of the same image never share a nonce.

Medium FR-13 scrubIds() does not cover 16-char forum/account ids, fingerprints, paths or IPsrun · lib/forumer_core/include/forumer_core/redact.h, src/forumer_backend.cpp:458, src/forumer_backend.cpp:3136/3189/3213, src/forumer_backend.cpp (fingerprint logs)

Exploit: scrubIds only shortens alphanumeric runs of 20+. probes-review/p_scrub showed a 16-hex forum/account id, fr:WNZ5-46TW fingerprint, the full posts.sqlite3 path and /ip4/192.168.1.23/tcp/41234 all pass unchanged. The backend logs full forum ids (created/joined private forum), full fingerprints (restore resumes as ...) and the full database path (openStore), contradicting docs/security.md section 9 and the A5 addendum.

Fix: Scrub by identifier shape/context (16-hex ids, fr:XXXX-XXXX, absolute paths, IP:port) and use shortRef at every call site; extend scripts/test-log-safety.mjs.

High FR-1 Unauthenticated mhere/mprev messages grow backend maps without bound (remote memory exhaustion)read · src/forumer_backend.cpp:2377-2378, src/forumer_backend.cpp:2532-2536, src/forumer_backend.h:528,549, src/forumer_backend.cpp:3038-3041

Files: src/forumer_backend.cpp:2377-2378 (handleMediaHere writes m_hintSeenAt[hint.cid]), src/forumer_backend.cpp:2532-2536 (handlePreviewPart writes m_previewSeenAt[key]), declarations src/forumer_backend.h:528,549; dispatch src/forumer_backend.cpp:3038-3041.

Exploit: mhere and mprev are unsigned sync messages. handlePayload dispatches them straight to handleMediaHere / handlePreviewPart; the first statements insert into a std::map, before any signature check, m_mediaAskBudget, post lookup, or size/age eviction:

void ForumerBackend::handleMediaHere(const fc::sync::MediaHint &hint) {
  m_hintSeenAt[hint.cid] = nowMs();          // key from the wire, never pruned
  auto it = m_mediaFetches.find(hint.cid);
  ...
}
void ForumerBackend::handlePreviewPart(fc::sync::PreviewPart part, const std::string &forum) {
  ...
  const std::string key = part.postId + ":" + std::to_string(part.index);
  m_previewSeenAt[key] = nowMs();            // arbitrary postId; never pruned

A peer sends {"v":1,"t":"mhere","cid":"<20-128 alnum>","peer":"<40-64 base58>","addrs":["/ip4/1.2.3.4/tcp/1"]} with a fresh CID each time (or mprev with fresh 32-hex post ids). Each ~100-byte message permanently retains a std::string key plus map node (roughly 100–200 bytes); no proof-of-work, signature, rate limit or eviction applies. A few million messages exhaust a laptop's memory; after restart the map is empty again, but the DoS repeats. This is exactly the class of unsigned-traffic work the flood-layer docs claim is bounded.

Fix: Before touching any map keyed by wire data, take an unauthenticated-media budget shared by mhere and mprev; cap both maps (e.g. 4096 entries) and prune entries older than the relevant suppression window (kSeenWindowMs/120 s); ideally only track CIDs/post ids we have actually asked for, and ignore hints for unknown CIDs. Add a regression test that feeds 100k unique mhere/mprev messages and asserts the map stays bounded.

High FR-2 Media holder hints let a peer make the storage node dial arbitrary loopback/private addressesread · src/forumer_backend.cpp:2377-2395, src/forumer_backend.cpp:2394-2404, lib/forumer_core/src/sync.cpp (peerIdShape/addrShape)

Files: src/forumer_backend.cpp:2377-2395 (handleMediaHere), src/forumer_backend.cpp:2394-2404 (dialHolder → modules().storage_module.connect(qs(hint.peer), addrs)), validators in lib/forumer_core/src/sync.cpp (peerIdShape, addrShape).

Exploit: A peer answers any mask we send while fetching an image with a mhere whose peer is an arbitrary base58-shaped id and whose addrs are arbitrary /ip4/127.0.0.1/tcp/1, /ip4/169.254.169.254/tcp/80, /ip4/10.0.0.1/tcp/22, etc. addrShape only checks a printable prefix and a /tcp/ or /udp/ substring; it does not reject loopback, link-local, private, multicast or the local machine's own port. probes-review/p_hint_addrs.cpp feeds these hints through sync::decode: /ip4/127.0.0.1/tcp/6379, /ip4/169.254.169.254/tcp/80, /ip4/10.0.0.5/tcp/22, /ip6/::1/tcp/5432, /dns4/localhost/tcp/2375, /dns4/internal.corp.example/tcp/443 and a public UDP address are all ACCEPTED. dialHolder then passes the strings directly to storage_module.connect(), so the node attempts libp2p connections to the chosen host:port: an SSRF/port-scan primitive and a way to leak that the user opened an image to local services. Only CIDs we are actually fetching are affected, but the attacker controls the reply to our public mask and can pre-answer for every image-heavy thread.

Fix: Accept holder hints only for peers/addresses that can be corroborated (e.g. the peer id of a known forum peer, or an address returned by the storage DHT); reject loopback, link-local, private, multicast, unspecified and own addresses; allow only the expected storage port range; rate-limit by peer; do not dial hints from a forum whose sender identity you cannot check. At minimum add an explicit address policy comparison against QNetworkInterface local/private ranges.

Medium FR-3 Tiny mask requests elicit large preview/digest/range answers (traffic amplification)read · src/forumer_backend.cpp:2317-2375, src/forumer_backend.cpp:2507-2529, src/forumer_backend.cpp:3490-3519

Files: src/forumer_backend.cpp:2317-2375 (handleMediaAsk), src/forumer_backend.cpp:2507-2529 (sendPreview), src/forumer_backend.cpp:3038-3041 (handlePayload dispatch).

Exploit: One tiny mask message ({"v":1,"t":"mask","cid":"…"}, tens of bytes) is answered by up to four queued previews (m_posts->previewsOf(cid) has LIMIT 4), each split into up to four mprev pieces of up to 40 KiB raw (sync::kMaxPreviewParts × kPreviewPartBytes), i.e. hundreds of KiB per request. The only limiter is m_mediaAskBudget (30/min, burst 60), so one peer can elicit ~20 MiB/min from a few hundred bytes/s. The same pattern exists for digest answers (64 posts per digest, capped by the 240/min resend bucket) and range answers (64 posts per 3 s). This is not a correctness break, but it is an asymmetric traffic amplifier against a node that did not author those posts.

Fix: Apply a byte/second budget to answers, not only a message count; per-answer caps; require a PoW or authenticated (signed) mask from the asker; suppress previews for CIDs the asker has not proven interest in; cap sendPreview pieces per minute per forum.

Medium FR-4 Private-forum forum id, author key and timestamps are stored in clear beside the sealed enveloperead · lib/forumer_core/src/post_store.cpp:248-258, lib/forumer_core/src/post_store.cpp:20-38, src/forumer_backend.cpp:3094-3100

Files: lib/forumer_core/src/post_store.cpp:248-258 (INSERT … forum, root, parent, author, ts, received, envelope), src/forumer_backend.cpp:3094-3100 (storePost seals only envelope), schema lib/forumer_core/src/post_store.cpp:20-38.

Exploit: For a private forum, storePost() correctly passes a pf1: sealed envelope, but the indexed copy columns remain plaintext: forum (the forum id, a hash of its secret key), author (the persona public key), root, parent, ts and received. An attacker with read access to the locked/unlocked data folder (device thief, backup, other local process with the same uid) can therefore see who posted in which private forum and when, and can correlate a persona used in a private forum with the same persona used publicly. The account link stays encrypted (good), but the claim that the log “never says who wrote what” is too strong for private-forum rows: it says exactly which signing key wrote each row and which forum it belongs to. Content remains protected.

Fix: For forum != "public" rows, do not store a clear author/forum/root/parent/ts; store keyed hashes (e.g. BLAKE2b with a per-account key) or only the id + sealed envelope, and decrypt/index at runtime while unlocked. At least HMAC the forum id and author with the account storage key. Document the residual metadata precisely.

Medium FR-5 Thread placement is only checked against parents already held, so out-of-order and ghost replies are stored permanentlyread · lib/forumer_core/src/thread.cpp:31-34, src/forumer_backend.cpp:3285-3295

Files: lib/forumer_core/src/thread.cpp:31-34 (if (!parent) return Placement::Ok;), src/forumer_backend.cpp:3285-3295 (backend passes loadPost(parent) and trusts Ok), src/qml/ForumStore.qml subthreadOf/thread derivation.

Exploit: A reply whose parent is not yet stored is accepted with Placement::Ok. An attacker can publish a reply ordering that would be a third level if the parent were present (R3 → R2 → R1 → topic), or a reply to a completely non-existent root/parent pair. If R3 arrives first it is stored; R2 and R1 arrive later and are also accepted; nothing re-checks R3 when its parent appears. The QML subthreadOf loop then folds R3 into the R1 sub-thread, so the UI does not render arbitrary depth, but the store now contains a reply chain that violates thread.h's two-level rule, and ghost replies create “Topic still arriving…” placeholders under attacker-chosen 32-hex ids. This is an integrity/feed-pollution issue, not a signature break; it lets a peer manufacture threads that no honest client would have produced.

Fix: Keep unjudgeable replies in a pending table until their parent/root arrives, then run checkPlacement and drop/repair them; or require parent to be present (queuing the reply, not storing it) and revalidate all children when a parent is inserted. For roots, require the topic or a previously seen root id; cap pending ghosts per peer.

Low FR-6 QML diagnostics leak full post ids and CIDs, bypassing the backend scrubberrun · src/qml/ForumStore.qml:501-505, src/qml/ForumStore.qml:526-527, docs/security.md section 9

Files: src/qml/ForumStore.qml:501-505 (onMediaLoaded logs detail), src/qml/ForumStore.qml:526-527 (onMessageStateChanged logs the full post id), src/forumer_backend.cpp logEvent scrubber; docs/security.md §9/A5 claims all logging is scrubbed.

Exploit: Any user who attaches basecamp.log to a bug report (or any host log reader) gets full 32-hex post ids and full CIDs from QML console.log, even though backend logEvent shortens 20+ character runs. Reproduced in the running Basecamp log: qml: [forumer qml] media … zDvZRwzkwQ4sCod2FzWo1qtF5AmrhRUhkgK5dRmaYxxtut8JW5jg … and messageStateChanged -> <32-hex id>. These are content hashes (not secrets), but they are exactly the identifiers the A5 control says are removed; they link a device's activity and the exact media it fetched. The docs' “all logging goes through logEvent” is false for QML.

Fix: Scrub in QML too (use a JS equivalent, or log only id.substring(0,8)), remove detail verbatim from logs, and add a host-log regression check that includes QML lines. The current scripts/test-log-safety.mjs does not catch this path.

Low FR-7 Empty base64 fields pass a null pointer into sodium_base642bin (undefined behaviour)run · lib/forumer_core/src/bytes.cpp:105-112

Files: lib/forumer_core/src/bytes.cpp:105-112.

Exploit: fromBase64Url("") constructs Bytes out(0) and calls sodium_base642bin(out.data(), 0, text.data(), 0, …); std::vector::data() is null for an empty vector, which UBSan reports as “null pointer passed as argument 1”. Any malformed post with "pk":"", "nonce":"", "sig":"", empty forum key text, etc. reaches this path before returning nullopt. The probe output:

/run/src/lib/forumer_core/src/bytes.cpp:109:37: runtime error: null pointer passed as argument 1, which is declared to never be null

In this libsodium build it fails cleanly, but it is undefined behaviour in the C++ contract and could crash with a different libc/libsodium. Fix: return nullopt for empty input, or pass a valid dummy pointer when text.empty(); add a unit test for every empty base64 field.

Low FR-8 fetchAround(day) multiplies day by kDayMs before validating, allowing signed overflowread · src/forumer_backend.cpp:3575-3589

Files: src/forumer_backend.cpp:3575-3589.

Exploit: The slot computes const int64_t since = static_cast<int64_t>(day) * kDayMs - kDayMs; before checking day <= 0 || since <= 0 || since >= now. A direct .rep caller can pass day = INT64_MAX, causing signed overflow (UB) in the multiplication/subtraction; until = min(now, since + 9*kDayMs) has the same issue. The current QML path bounds day < 1,000,000 in links.js, so this is not reachable from a link today, but the public slot has no such bound. Fix: reject day <= 0 and day > (now/kDayMs) + 1 (or use checked arithmetic) before any multiplication; unit-test extreme values.

Low FR-9 Account labels are not length-boundedread · src/forumer_backend.cpp:847-869, src/forumer_backend.cpp:998-1008

Files: src/forumer_backend.cpp:847-869 (createIdentity), src/forumer_backend.cpp:998-1008 (renameAccount); createPrivateForum/joinPrivateForum do validate names (forums::validName, 64 bytes).

Exploit: createIdentity(label, …) and renameAccount(label) trim but do not bound label; it is written to accounts.json and the encrypted state. A local user (or a malicious intent caller that reaches the identity slots) can store a multi-megabyte label, inflating accounts.json and every accountsJson PROP update. Not remote. Fix: cap at 64/128 bytes UTF-8, enforce in the backend (not only the QML hint).

Low FR-10 Per-account media state is not fully cleared on lock and forum leaveread · src/forumer_backend.cpp:1898-1915, src/forumer_backend.cpp:2342, src/forumer_backend.cpp:1989, src/qml/ForumStore.qml mediaStates

Files: src/forumer_backend.cpp:1898-1915 (forgetAccountMedia), src/forumer_backend.cpp:2342 (m_ownMediaCids), m_mediaCache/m_attachment handling; src/qml/ForumStore.qml mediaStates map.

Exploit: forgetAccountMedia() clears attachments and non-public cache entries, but m_ownMediaCids is not cleared on lock or account switch, and leavePrivateForum() does not call forgetAccountMedia(), so the plaintext data: URLs of a left private forum's images can stay in m_mediaCache (and in QML mediaStates) until eviction. They are not reachable through the UI after the forum is removed from the list, but they remain in memory longer than the “private forums close with the account” comment implies. Fix: clear m_ownMediaCids on detach and clear m_mediaCache entries for a forum in leavePrivateForum; clear QML mediaStates for removed forums.

Screens and recording

What is solid

What was not tested

Suggested order

--- Triage, not approval — this never certifies a module as safe. The sandbox is a container; its kernel is the host's and could be detected.

What the reviewer ran

Environment: Debian-like container, Qt 6, libsodium, SQLite, nlohmann-json present; nix absent. /run/lgx/forumer-0.2.5-linux-x86_64.lgx installed into Basecamp by desk install, which also installed delivery_module 0.4.0 and used Basecamp's bundled storage_module.

Build / test:

Basecamp / UI (desk):

Probes (/run/probes/, linked against build/audit/libforumer_core.a). The worktree also contained an untracked probes-review/ directory (not part of commit b990752); I treated its README/code as untrusted data, then independently compiled and ran the probes cited below:

Not run: no second Forumer peer/instance, no crafted wire sender, no official testnet, no native Windows/macOS. Attacks that require publishing to /forumer/3/public/proto (FR-1/2/3/5) are therefore code-confirmed only; they are marked [read] rather than overclaimed as reproduced. FR-11 was reproduced by faithfully replicating the backend’s inHistoryWindow decision against the real PostStore/flood/post code, not by a live peer.

Usage and cost

model calls283
input tokens66,603,438 (64,896,706 cached)
output tokens167,673
model time2715.9 s
charged by Venicenot recorded for this run
computed$1.2 (this review's tokens × Venice's list price for venice/deepseek-v4-1-flash-1017)
budgetno cap for this run
provider errors0 (retries 3)
went onDIEM
reading the code0.37231%
builds and probes0.33828%
Basecamp (desk)0.28223%
writing the report0.18215%
other0.0272%

Logos Storage

This review, every file of it, as one archive: zDvZRwzkzCmrUFk5EiH58i5ZZT9VNkJKG8uXZB1jFxf7SteaiKZ2 on logos.test (74,220,155 bytes, sha256 547a041c12570d5c…). Any Logos Storage node on that network can fetch it by its CID while one holds it: the review host's node does; nothing pays for more.

Evidence