Skip to content

Security ​

What the credential can do ​

The blast radius depends entirely on which kind you use, and the three differ enormously:

CredentialSees
Service accountOnly the properties it was explicitly added to. Revocable on its own
OAuth2 refresh tokenEverything the consenting person sees, in every account. Scopes fixed at consent
Application defaultWhatever the machine is logged into — usually the widest

The service account is the recommended one for exactly this reason. Nothing about this server makes an OAuth token safer than the account behind it.

Scopes follow the tools ​

The OAuth scopes requested at startup are derived from the tools that are actually registered — see scopesFor in src/auth.ts. Denying the Indexing tools means the Indexing scope is never requested.

GSC_READ_ONLY=true additionally swaps webmasters for webmasters.readonly, which makes writing impossible below the tool layer rather than only above it.

The one gap worth naming: Site Verification has no read-only scope. Google's siteverification.verify_only is write-only and cannot list, so the read tools there need the full scope. That is a limit of Google's scope design, not an oversight here.

Irreversible operations ask a person ​

Four tools put the question to a person before they act, through MCP elicitation — a dialog the model cannot answer on its behalf, and which nothing proceeds without:

ToolWhat it costs
delete_siteRoughly 16 months of performance history, discarded. Re-adding starts empty
unverify_siteOwner access to the site; every property for it drops to 403, and the Indexing API stops working
update_site_ownersEveryone not in the list you pass. It is the complete owner list afterwards
delete_sitemapSubmission history and error report. The least severe of the four — submit_sitemap puts it back

A plain boolean confirm parameter would not do. The model could set it on the first call, or be talked into it by instructions hidden in upstream content — and this server hands the model plenty of that.

Where the client cannot show a dialog, the tool refuses its first call and returns a short-lived, single-use token bound to those exact arguments. That token only ever appears in a previous tool result, so it cannot be guessed — but be clear about what it proves, because this server is: the call was made twice with the same arguments, and nothing more. A model can read it out of the first result and quote it back in the same turn. The fallback text says so rather than implying somebody approved, and names whether it was the client that could not be asked or the operator who switched the dialog off with ELICITATION=false.

Either way the approval is bound to a resource key, so one issued for one property cannot be replayed against another.

See Asking a person.

Untrusted content ​

Everything the APIs return is marked as untrusted, and "it is only search data" is exactly the wrong intuition:

  • Search queries are strings arbitrary members of the public typed into Google, handed to a model verbatim.
  • Page titles, canonical URLs and crawl diagnostics come from whoever runs the crawled site.

Someone who wants a model to act on their instructions can put them in a page title and wait to be crawled. Confirmation prompts therefore never quote anything that came from an API — no property label, no page title, nothing.

Credentials never reach the output ​

  • Deleted from the environment after start-up, so they are not visible to child processes or in /proc/<pid>/environ.
  • redirect: 'error' on every request: a redirect away from Google cannot carry the access token with it.
  • A rejected value is described rather than echoed. GSC_REFRESH_TOKEN and GSC_ALLOW_TOOLS are adjacent lines in every compose file, and a paste into the wrong one must not print the secret into the client's log.
  • The startup line names which kind of credential is in use, never the value.

No SSRF guard, on purpose ​

The sibling servers in this family all carry a hosts.ts that refuses loopback, link-local and cloud-metadata addresses. This one does not, and that is a decision rather than an omission: no target host is configurable here. Every request goes to one of three hard-coded Google endpoints, so there is no attacker-controlled destination to guard against.

What Google sends ​

Every request goes to one of three Google hosts, but what answers is whatever sits on the path — a TLS-inspecting proxy, a captive portal, an outbound filter — and Google's own JSON changes. So the response is held to a shape rather than trusted:

  • The status is decided before the body is read. An error body is read under a 64 kB ceiling and cut, never refused, so a 429 or 503 with a large body is still retried and still explained. A success body is read under the ceiling for its endpoint: 1 MB for a record, 8 MB for a URL inspection, 64 MB for a search analytics query, whose size the caller chose.
  • Every field a result promises is checked at the boundary: a row that is not an object is dropped, a key that is not a string is written out as one, a metric that is not a finite number is left out, a null site block is not a site, an empty 200 is an empty record. One odd entry never takes down the listing it is in, and the output schema is never what refuses an answer.
  • Control characters are stripped and lone surrogates repaired in both channels. Text quoted from an error body is labelled as somebody else's, cut and stripped.
  • The access token is checked before it becomes a header, so a token the runtime would refuse is reported as no token rather than quoted.

Budgets ​

A tool result is capped at 100 kB in each channel. List results drop whole entries rather than slicing the JSON — a truncated document is not a smaller answer, it is an unparseable one — and say how many were dropped and how to narrow the request. A single oversized object has its largest fields shortened at any depth, each marked, so the structure survives. query_search_analytics carries in its structured half exactly the rows its table shows, with a truncated block naming the next start_row.

The budget counts bytes, not characters. Search queries are the most multilingual free text there is, and a character budget would let through three times what it promises.

Caller strings have ceilings too — 2 048 characters for a property, a URL or a resource id, 254 for an owner address, 35 for a language tag — and a batch call (submit_sitemaps, inspect_urls) works under a two-minute budget checked before each request; when it runs out, the result says how many entries were attempted and that the rest were not.

Reporting a vulnerability ​

Use private vulnerability reporting, never a public issue, and do not include real credentials or property names.

Released under the MIT License.