README.md
Rendering markdown...
# Code-level analysis — CVE-2026-56782
## 1. Route registration
`master/rest.go` wires the two endpoints onto the master HTTP container (the same
server that serves the dashboard on port `8088`):
```go
container.Handle("/api/dump", http.HandlerFunc(m.dump))
container.Handle("/api/restore", http.HandlerFunc(m.restore))
```
These are plain `http.HandlerFunc` handlers — they are **not** behind the
dashboard's cookie/session middleware. Each handler is responsible for its own
authorization, which it delegates to `checkAdmin()`.
## 2. The fail-open check
```go
func (m *Master) checkAdmin(request *http.Request) bool {
if m.Config.Master.AdminAPIKey == "" {
return true // (A)
}
if request.Header.Get("X-API-Key") == m.Config.Master.AdminAPIKey {
return true // (B)
}
return false // (C)
}
```
The intended model is (B)/(C): a request is admin iff it presents the configured
key. But branch **(A)** short-circuits when the key is empty and returns `true`
*before the header is ever read*. So when `admin_api_key` is unset:
* a request with **no** `X-API-Key` → admin,
* a request with a **wrong** `X-API-Key` → admin (the header is never compared).
`config/config.toml` ships:
```toml
# Secret key for admin APIs (SSL required).
admin_api_key = ""
```
so a default deployment is in state (A): unauthenticated admin for anyone who can
reach the port.
## 3. What each handler does once admin is granted
```go
func (m *Master) dump(response http.ResponseWriter, request *http.Request) {
if !m.checkAdmin(request) { writeError(response, 401, "unauthorized"); return }
if request.Method != http.MethodGet { writeError(response, 405, ...); return }
response.Header().Set("Content-Type", "application/octet-stream")
// streams users, then items, then feedback (see §4)
}
func (m *Master) restore(response http.ResponseWriter, request *http.Request) {
if !m.checkAdmin(request) { writeError(response, 401, "unauthorized"); return }
if request.Method != http.MethodPost { writeError(response, 405, ...); return }
stats, err := m.Restore(request.Body, nil) // overwrites the dataset from the body
...
}
```
`dump` walks `GetUserStream` / `GetItemStream` / `GetFeedbackStream` straight out
of the data store. `restore` feeds the request body back into the store. No further
authorization, scoping, or confirmation is applied.
## 4. Dump stream format
The body is a flat binary stream (`writeDump` / `readDump`):
```
section marker int64 LE : -1 = users -2 = items -3 = feedback 0 = EOF
record int64 LE length prefix (> 0) followed by that many protobuf bytes
```
A reader distinguishes the two by sign: a non-positive int64 is a section marker /
EOF, a positive int64 is the length of the next protobuf record. So the layout is:
```
[-1][len][User pb][len][User pb]... [-2][len][Item pb]... [-3][len][Feedback pb]... [0]
```
`exploit.py` parses exactly this to count records per section and to pull field #1
(the `UserId` / `ItemId` string, protobuf tag `0x0A`) from a few records as a sample
— turning the raw 262 MB stream into a concrete "2079 users / 22320 items / 331901
feedback exfiltrated" impact statement.
## 5. The fix (0.5.10)
`0.5.10` removes the `checkAdmin()` fail-open: an empty `admin_api_key` no longer
returns `true`, so `/api/dump` and `/api/restore` reject unauthenticated requests
(HTTP 401) regardless of configuration. This is the binary discriminator between
**VULNERABLE (< 0.5.10)** and **PATCHED (≥ 0.5.10)** — the PoC's `restore_probe()`
reports `401` against a patched server and `200/500` against a vulnerable one.