5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / ANALYSIS.md MD
# 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.