Who Is Asking?
Every request to a database carries three questions:
- Who is asking?
- What are they allowed to touch?
- How much should they get?
For most of MDDB's history the answers came from whichever handler happened to receive the request. This release moves them to one place each. It also found, on the way, that some handlers never asked at all.
The full list is in the changelog.
Who is asking #
MDDB rate-limits clients by IP address and records failed logins under it.
Until this release it took that address from the first entry of
X-Forwarded-For, from anyone. A client that sent a different header on each
request was never limited. A burst of failed logins landed under addresses the
attacker chose, so the detector that counts them never fired.
Behind a proxy it was no better. nginx appends the real client to whatever
X-Forwarded-For the client sent, so the first entry is still the client's
own text.
Now forwarding headers count only from proxies listed in
MDDB_TRUSTED_PROXIES, and the chain is read from the right, skipping those
proxies. The default list is empty: MDDB trusts nobody until told otherwise.
Not every proxy speaks X-Forwarded-For:
| In front of MDDB | Sends the client as |
|---|---|
| Cloudflare and cloudflared | CF-Connecting-IP |
| Ivanti (Brocade) vTM | X-Cluster-Client-Ip |
| NetScaler | a header the admin names |
HAProxy in TCP mode, AWS NLB, F5, nginx stream |
no header; PROXY protocol |
So MDDB_CLIENT_IP_HEADER picks the header, Forwarded (RFC 7239) is
understood, and MDDB_PROXY_PROTOCOL=on reads PROXY v1/v2 on the HTTP, gRPC
and MCP listeners. The configuration docs carry one row per product, each
checked against the vendor's own documentation. Where a vendor's behaviour
could not be confirmed, the row says so.
What may they touch #
MDDB has per-collection permissions and tenants. A key can be limited to one
collection, and a tenant user sees only its own namespace. One function,
CheckPermission, enforces both. The trouble was that each handler had to
remember to call it.
While building the Elasticsearch subset (below) we noticed that /v1/fts
never did. So we checked every entry point:
| Surface | What did not check |
|---|---|
| REST | about thirty-five routes, among them vector-search, hybrid-search, add-batch, ingest, schema/*, webhooks and memory/* |
| MCP | every tool and every resource |
| gRPC | synonyms, stopwords and GeoStats |
| GraphQL | schema, schemas, stats and vectorStats |
With authentication on, a key limited to one collection could read, and in places write, any other. A tenant could reach another tenant's documents.
There is now one rule per entry point, in a table, applied before the handler runs. A request needs the permission on every collection it names, including each target of a cross-collection search. An entry point with no rule is refused. The tests list every route, RPC, MCP tool and GraphQL field the server has and fail if one is missing from the table, so a new endpoint cannot quietly skip the check again.
The review found one more thing. Admin rights on a single collection set the
global admin flag in the user's token, and that flag passes every check. An
admin of docs was an admin of the whole database. Now only admin on "*"
is global.
If you run with authentication enabled, read the upgrade notes. Tenant users
now get 403 on listings that are not filtered by tenant, and login tokens
issued before the upgrade keep their old admin flag until they expire.
How much should they get #
A public search page asks for 50 results. Nothing stopped a script from
asking for 100,000: one request to /v1/fts on daemonports.org returned
21 MB in 1.4 seconds. A rate limit counts requests, not bytes.
MDDB_SEARCH_MAX_LIMIT (1000 by default) now caps every search on every
surface, through the one function that already resolved the result count.
A collection can lower the cap but never raise it, because the server value
is the operator's limit and anyone with config access to one collection
should not be able to lift it. A capped response says limitCapped: true, so
a client knows why it got fewer results than it asked for.
Building the cap turned up a smaller version of the same problem. Facet values were sorted with an insertion sort. On a field with 200,000 distinct values that took eleven seconds.
Writing less #
The same site reported that adding one metadata key to each of its 111,000 documents would take three to four days on spinning disks. Every update, even one that changed only metadata, deleted and rewrote every full-text posting of the document.
The index now keeps a signature of each document:
- A resent document writes nothing.
- A metadata change rewrites only the fields that changed.
- New content still re-indexes the whole document.
Adding one key to a document of about 300 words used to rewrite 306 keys across 576 pages. Now it writes 5 keys and 15 pages.
A shortcut like this is only safe if it gives the same answer as rebuilding from scratch. A property test applies thousands of random edits and, after each one, compares the index byte for byte with a fresh rebuild, including the statistics BM25 scores with.
That work also exposed three paths that left the index lying:
- Batch updates over gRPC and MCP never re-indexed full text at all.
- They also never queued new embeddings, so vectors kept describing the old text.
- Emptying a document's content left it findable by the words it no longer had.
All three are fixed, and one test now sends the same update through fourteen write paths and checks that each has the same effects.
Ready sooner #
After a restart, a collection of 1,000 vectors waited 1 minute 39 seconds before answering anything. The server trained its IVF and product-quantisation indexes first and only then marked the collection searchable, so flat and HNSW search, which need no training, waited too.
Training now runs in the background. The collection answers after 0.12
seconds. algorithm: "auto" skips an index that is still training and says
so in explain.
Speaking other dialects #
- Elasticsearch. Some tools only know how to talk to Elasticsearch.
MDDB_ES_COMPAT=truemounts a subset of its API under/es:_search,_doc,_bulkand_count, withmatch,bool,term,range,knn, highlights andtermsaggregations. Each clause is translated onto MDDB's own full-text, vector and hybrid search. There is no second engine. Anything outside the subset is refused with its name, never ignored. The officialelasticsearch-pyclient runs unmodified against it in CI.GET /essays plainly that this is a subset, not Elasticsearch. - Corpus packs.
mddb-cli corpus buildpacks a collection, with its full-text index and vectors, into one file with a licence in its manifest.corpus installmounts it read-only on another server, without re-embedding. If the other server uses a different embedding model, the pack still installs, answers from full text, and says why. - Synced sources. A scheduled rule can keep a collection in step with a sitemap, a list of URLs or an S3 prefix. It fetches only what changed, and deletes a document only when its source is gone and the listing was complete. An S3 source is only ever read: a mistyped bucket fails the run instead of creating an empty bucket.
Asking for help without handing over data #
When something goes wrong on someone else's server, we used to ask for half a
dozen endpoints, one at a time. mddb-cli diagnose collects them into one
bundle: build, configuration, statistics, metrics, recent error classes. It
leaves out document content, metadata values, query text, keys, addresses and
paths. Collection names are hashed unless you ask otherwise.
A test plants unique strings in every place user data could hide. It then checks that none of them reaches any file in the bundle. With each redaction removed in turn, all eleven variants of that test fail.
The bundle also carries counts of how queries are shaped. Two planned features, graph queries and community detection, have been waiting for evidence that anyone needs them, and these counts are how that evidence can arrive without anyone sending us their questions.
Upgrading #
Without authentication, almost nothing changes. Two defaults are new:
- Searches return at most 1,000 results; raise
MDDB_SEARCH_MAX_LIMITif you need more in one call. - Behind a proxy, set
MDDB_TRUSTED_PROXIES. Until you do, the rate limiter and the audit log see every client as the proxy.
With authentication, permissions are now enforced where they were not, and
some clients that worked by accident will get 403. The upgrade section of
the changelog lists every case.