# Security

This document is the threat model, the data-handling policy, and a checklist of the
security guarantees this server makes, with pointers to where each is enforced.

## Reporting a vulnerability

Please report security issues privately to the maintainer (open a GitHub security advisory
on this repository, or email the address in the git history) rather than a public issue.
You will get an acknowledgment within a few business days.

> **Maintainer prerequisite, before this repository is made public.** Turn on _Private
> vulnerability reporting_ (Settings → Code security and analysis). Until it is on, the
> "Report a vulnerability" button does not exist for anyone outside the repository, and the
> advisory links in `CODE_OF_CONDUCT.md` and `.github/ISSUE_TEMPLATE/config.yml` lead to a page a
> reporter cannot use. The failure is silent and lands on the one person doing the right thing,
> whose only remaining option is the public issue tracker this document tells them not to use.

## Secret scanning: what it covers, and the two things it does not

`.github/workflows/secret-scan.yml` runs gitleaks two ways: a per-change gate on push and pull
requests, and a weekly full-history audit. Both are checked by tests, because the first version of
that file could not run at all and the second could not see history, and a green suite said nothing
about either.

Two gaps are real and neither is fixable in the config. Both belong on the go-public checklist.

- **Only the default branch is audited.** The scheduled run checks out the default branch, and
  gitleaks walks from the checked-out HEAD, so commits that exist only on an unmerged branch are
  scanned by nothing. `--log-opts=--all` does not widen it: measured against gitleaks 8.30.1, the
  commit count does not move (two-dot ranges do work). The answer is housekeeping rather than
  configuration, so **merge or delete stale branches before going public.** At the time of writing
  the unmerged branches were scanned by hand and are clean.
- **A scheduled workflow on a public repository is disabled automatically after 60 days without
  activity.** An audit is precisely the thing nobody is waiting on, so it is also the thing whose
  disappearance nobody notices. Re-enable it, or trigger it manually, after any long quiet period.

**"No leaks found" is a weaker statement than it reads as.** gitleaks gates its `generic-api-key`
rule on Shannon entropy, so a value that looks exactly like a credential can pass under the
threshold: same file shape, same key name, same length, reported or not depending on how the
characters fall. Measured over random 32-character hex, roughly one value in five sits below the
cut. A clean scan therefore means "nothing matched the rules", not "there are no secrets here", and
it is a backstop rather than the reason the repository is safe to publish. The reason is that no
credential was ever committed, which the per-value triage in the git history establishes separately.

## What the server holds, and for how long

- **Credentials live only in memory.** The management API key pair and any bring-your-own
  SQL user exist for the process lifetime; ephemeral SQL user passwords exist only while
  cached (TTL, default 10 minutes). Nothing is written to disk.
- **Query results and SQL statements are never persisted.** They exist in memory for the
  duration of a request only. Structured logs record tool name, target cluster id, status,
  latency, and (where available) an upstream request id, and are passed through a redaction
  layer before any sink. Logs never contain SQL text, result rows, or credentials
  (`core/src/observability/logger.ts`, `core/src/safety/redact.ts`).
- **No token passthrough.** The remote server never forwards a client-supplied token
  upstream to TiDB Cloud. Only credentials issued to or derived by this server reach TiDB
  Cloud.

## Credential model

- **Ephemeral, least-privilege SQL users.** Data operations use short-lived SQL users
  minted via the IAM API. Read tools receive a `role_readonly` user, write tools a
  `role_readwrite` user; least privilege is enforced by the database grant, not by
  inspecting SQL (`core/src/auth/credential-manager.ts`). Users are cleaned up on session
  end, on TTL rotation, and by a startup/first-use sweep of `mcp-eph-*` users; a manual
  `tidbcloud-mcp cleanup <clusterId>` command purges orphans.
- **Bring-your-own SQL user** is an escape hatch; its privileges are whatever you granted
  it, so read-only mode's by-privilege guarantee does not extend to it.

## Safety guarantees (checklist)

| Guarantee                                                                                                                                       | Where                                                           |
| ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Read-only mode registers only read tools and mints only read-only users                                                                         | `core/src/tools/register.ts`, credential manager                |
| The local server is read-only by DEFAULT; writes need `--allow-write` / `--allow-admin` at startup                                              | `core/src/config/resolve.ts` (`resolveLocalConfig`)             |
| Mutating tools are split into permission groups (`data` = DML/DDL vs `admin` = cluster/branch/SQL-user); a grant of one never implies the other | `core/src/tools/register.ts`, `core/src/config/resolve.ts`      |
| Every mutating/destructive/billable tool runs a two-step preflight; no action without `confirm: true`                                           | `core/src/tools/preflight.ts`                                   |
| Destructive tools also require `confirmTarget` to match the resolved target                                                                     | `core/src/tools/preflight.ts`                                   |
| `tidb_create_cluster` defaults the spend limit to 0 (free tier)                                                                                 | `core/src/tools/management.ts`                                  |
| Identifiers are backtick-escaped (property-tested); values are bound parameters                                                                 | `core/src/safety/identifiers.ts`                                |
| SQL user management uses the IAM REST API, not string interpolation                                                                             | `core/src/api/sql-users.ts`                                     |
| Query results are previewed (first N rows) so one query can't flood the agent context                                                           | `core/src/safety/result-preview.ts`                             |
| Tool errors are redacted of known secrets at the boundary (thrown and returned error results)                                                   | `core/src/tools/framework.ts`                                   |
| Multi-statement SQL is blocked at the driver (`multipleStatements: false`) and by the guard                                                     | `core/src/data/connection.ts`, `core/src/safety/query-guard.ts` |

These are verified by unit tests, a property test for escaping, a leak-test harness, and an
end-to-end safety-eval suite driven through a real MCP client.

## Remote endpoint hardening

- **Auth required** for internet-facing use. v1 uses **per-user API keys** (bring-your-own):
  a key pair via Basic / Bearer / headers, with per-key isolated contexts and IAM validation.
  An OAuth 2.1 resource-server mode is built but dormant (see below); "log in with TiDB Cloud"
  delegation and stored key custody are backlog, not shipped.
- **OAuth 2.1 resource server**: protected-resource metadata (RFC 9728), and JWT validation
  with issuer and **audience** checks (RFC 8707), the defense against token
  passthrough/confused-deputy (`remote/src/oauth.ts`).
- **Scopes only narrow, never expand.** The effective permission set is the intersection of
  the token/key policy, URL params, and server config (`remote/src/scope.ts`,
  `remote/src/oauth.ts`). OAuth scopes map to permission groups: `tidb:read` is read-only,
  `tidb:write` adds the `data` group (DML/DDL) only, and `tidb:admin` additionally adds the
  `admin` group (cluster/branch/SQL-user mutators). So a `tidb:write` token can run a
  migration but cannot delete a cluster.
- **Origin validation + CORS**: never `*` with credentials. With an allowlist
  (`TIDB_MCP_ALLOWED_ORIGINS`) a matched Origin is echoed with credentials mode and a
  mismatched one gets 403; without an allowlist the Origin is echoed but credentials mode is
  not asserted, so a hostile page cannot mount credentialed CORS (auth is header-based, not
  cookie-based). **Rate limiting** per credential/IP (`remote/src/middleware.ts`).
- **Read-only by default** on the remote; writes require an explicit opt-in.

## Known limits (honest)

- **Preflight and `confirmTarget` are filled in by the model, not by you.** They are a consistency
  check that catches acting on the wrong resource, and they force the plan to be surfaced. They are
  NOT a human authorization boundary: a model can supply `confirm: true` and the right
  `confirmTarget` on its own turn, and in our own NL evals one did exactly that, unprompted. The
  real human boundaries are (1) the startup flag, since a model cannot add `--allow-admin` to a
  command line you typed, which is why the local default is read-only, and (2) your MCP client's
  own approval prompt for tools marked `destructiveHint`. Treat the preflight as a safety rail, not
  a lock.

- Column-level PII redaction is out of scope for v1: the server cannot reliably classify
  columns. Mitigations are read-only defaults, result previews, and pointing the server at
  dev/test data.
- Ephemeral SQL usernames (`mcp-eph-*`) can appear in database error text (e.g. a grant-denied
  message). By design the username is a non-secret, auditable marker and the password never
  appears, so this is not a credential leak; noted for completeness in case a future threat
  model treats the username as sensitive.
- Org-wide policy enforcement (admin-forced read-only, cluster blocklists across all
  clients) cannot be guaranteed by this server alone; per-deployment config is enforced,
  but true org policy is a TiDB Cloud backend capability.
- Tool input schemas rely on the MCP SDK, which strips unknown keys rather than rejecting
  them; this fails safe for the preflight/confirm flow.
- CORS is enforced on the preflight (`OPTIONS`) and on error responses; the streaming `POST
/mcp` success response is produced by the MCP transport and does not carry
  `Access-Control-Allow-Origin`. Non-browser MCP clients (the common case) are unaffected;
  a browser-based client behind an allowlisted Origin would need that header added to the
  streamed response. Tracked as a follow-up.
