Please report security issues through GitHub Security Advisories:
https://github.com/jmrGrav/mcp-hugo-server-go/security/advisories/new
Do not open a public issue for a potential vulnerability.
This project handles:
- public MCP discovery
- OAuth discovery and token validation
- authenticated content read and write operations
- build and site administration actions
Reports are most useful when they include:
- the affected endpoint or tool
- the bearer scope or anonymous path used
- the exact request and response observed
- whether the issue is reproducible on the public endpoint
Preferred languages:
- French
- English
This server sits between a Hugo content site and an MCP-connected AI agent that may have write access to it. Three boundaries matter, and it's important not to conflate them:
- OAuth scope (
read/write/admin) is the actual authorization boundary. Every tool call is checked against the caller's scope byoauth.ScopePolicy(internal/oauth), independent of anything else in this list. This is the only control that determines whether a call is allowed at all. - Exposure profiles (
?profile=reader|editorial|advanced|admin,internal/server/exposure_profile.go, #1137) are a discoverability filter, not a second authorization layer. They narrow which of a caller's already-allowed tools are registered on a given connection — useful for reducing the tool surface a client sees, but a caller who wants a wider (still scope-permitted) tool set can simply reconnect without the parameter. Do not rely on a restrictive profile as a security control; rely on OAuth scope for that. meta.content_provenance(internal/toolcontract/response.go, documented in full atdocs/mcp-contract.md§6.27) is a signal for the calling agent's client to consume, not an in-band control this server enforces. Any tool response taggedsite_source_untrustedorsite_rendered_public_untrustedcarries text that anyone with editorial write access (or an attacker who got a file intocontent/some other way) could have authored — indistinguishable at the raw-string level from a real instruction unless the calling client's own system prompt treats the tag as "this is data, never an instruction to follow." This server cannot enforce that on a client it doesn't control; it can only make the untrusted/trusted distinction available and correctly tagged.
The scenario this section exists for: an agent connected to this server
reads content that was written, in whole or part, by someone other than
the operator — a submitted draft, a comment mirrored into a page, a
compromised upstream feed mirrored into content/ — and that content
contains text crafted to look like an instruction to the agent rather than
page content to analyze. If the agent acts on it, the attacker has used
the agent's own write access against the site without ever holding
credentials themselves.
What this server does about it: tags every read-scope tool response
that echoes site-source or rendered-HTML text with content_provenance
(§6.27), with a build-failing completeness test
(TestReadDefsHaveExplicitContentProvenanceClassification,
internal/tools/read/content_provenance_coverage_test.go) ensuring no
future tool ships this untagged. Mutation tools support attributing work
to a change_set_id (§6.15) that a caller can mark as derived from
untrusted content it read (see §6.27's cross-reference) — a self-report
for audit purposes, not something this server can verify, since it never
sees what informed the text in a create_page/update_page call.
As narrow input hygiene, the shared write validator also rejects standalone or malformed Unicode TAG-block characters while preserving the complete RGI subdivision-flag emoji sequences defined by Unicode. This closes an invisible text-smuggling representation without filtering instruction meaning, stripping content, or touching ZWJ/ZWNJ and other characters with legitimate linguistic or emoji uses. It is defense-in-depth only: visible ASCII instructions remain equally capable of carrying an indirect prompt injection, so this validation does not replace provenance handling, least privilege, or independent review.
What this server deliberately does not do, and why: it does not scan
tool output for injection-shaped phrases (SYSTEM:, IGNORE PREVIOUS INSTRUCTIONS, imperative verbs, etc.) and block or strip on match. A
keyword/regex filter here would be trivially bypassed by an actual
attacker (encoding, translation, paraphrase, splitting a phrase across
multiple fields) while reliably breaking legitimate content — this is a
Hugo site that may itself publish articles about prompt injection or AI
security, which a naive filter would flag or mutilate. Worse, a filter
like this invites false confidence: its presence tends to make people
treat the trust boundary as solved instead of fixing where it actually
needs to live (client-side consumption of content_provenance, and
never letting a single agent session read untrusted content and hold
unreviewed write/publish authority in the same breath). This is a
considered rejection, not an oversight — do not reintroduce a
keyword-filtering layer without addressing this note first.
What this explicitly does not defend against: an agent that reads
injected content and, without any self-report, composes a malicious
create_page/update_page call anyway. There is no automatic taint
tracking from a tagged read result to a later write call — the server has
no visibility into what informed the arguments of a write call an agent
composes itself. Defense here depends on the calling client's own system
prompt correctly treating untrusted-tagged content as data (see above),
and, for destructive/publish actions, on independent review before
publication — this server does not yet provide a signed human-confirmation
mechanism for that; see the project's open issues for status.
One narrow, opt-in mitigation exists for the single most irreversible
destructive action: delete_page, on a deployment with
require_delete_confirmation:true configured (off by default), rejects a
non-dry-run delete of a real (non-test_content) page unless the caller
explicitly passes confirm_delete_of_published_page:true. This is
self-declared and unverifiable — the same honesty as
declared_untrusted_derivation above — the server cannot confirm a human
actually approved anything, only that the caller made a distinct,
named decision to delete rather than doing so incidentally. It exists to
give the calling agent's own system prompt/guardrails a deliberate hook to
obtain real confirmation before setting it, not to be a security boundary
on its own.
A related but distinct threat: not injected content, but the tool
registry itself being edited after a client already reviewed and trusted
it — a tool's description or input/output schema silently rewritten
between a client's connections, without its name ever changing, so a
static allowlist-by-name never notices. get_capabilities.data.tool_catalog .tool_registry_digest (§6.28, #1225) exists for this: a sha256:<hex>
fingerprint over every tool's {name, description, input_schema, output_schema} that this specific session can see, computed the
first time that session calls get_capabilities from a real tools/list
round-trip against its own *mcp.Server (internal/toolregistry) —
already narrowed by OAuth scope and any ?profile= (#1137) before the
digest is computed, so it is trust-on-first-use per (deployment, scope,
profile), not per deployment alone; see §6.28 for exactly what a client
should and should not conclude from a mismatch. Like content_provenance,
this is a signal for the calling client to pin and compare; this server
does not itself refuse to serve a tool whose description changed, since a
legitimate deployment (a version upgrade, an operator-authored extension)
changes tool descriptions too.