Skip to content

Security ​

This page is the prose version of SECURITY.md.

Trust model ​

The credentials this server holds are ntfy credentials, and what they grant depends entirely on what the ntfy account is allowed to do — so the answer is "as much as you gave it", and the useful advice is to give it little.

  • A publishing token can send notifications to the topics it is granted. Those notifications reach real devices, so the practical damage is noise, plausible phishing in your own alerting channel, and — if the account also has read access — a way to move data off the instance by publishing it to a topic somebody else is subscribed to.
  • An admin account can enumerate every account on the instance, create accounts, and grant or revoke access to any topic. That is the whole authorization model of the ntfy server. Do not give this server an admin account unless you are actually using the user and access tools, and prefer a dedicated non-admin account with write-only access to the topics it needs.

Treat every environment variable this server reads as a secret. The MCP client process, and therefore the model driving it, sees every tool result.

A topic name is a bearer credential ​

On an instance with the default auth-default-access, anyone who knows a topic name can subscribe to it and publish to it. There is no membership to check and nothing to revoke — the name is the credential.

Two things follow. Set NTFY_TOPICS so this server can only touch the topics you meant, and treat the names the way you would treat a password. The server does its part: the account's sync_topic never leaves get_account, and the first NTFY_TOPICS entry is the default for tools called without a topic, so the name does not have to travel through the tool arguments to be used.

The list bounds the two account tools as well, which is less obvious than it sounds, because neither of them takes a topic the way a publish does. manage_user_access takes a pattern, and a pattern is a promise about topics that do not exist yet — deploy* covers every future topic starting with those letters and * covers all of them — so where NTFY_TOPICS is set, a wildcard is refused outright and a plain name has to be on the list. list_users takes no topic at all and returns them: an unfiltered /v1/users is the one endpoint on ntfy that answers "which topics exist here", for every account at once. Its grants are therefore restated against the allowed topics — a wildcard grant appears once per allowed topic it covers, and a grant that covers none of them is not shown.

Writes are registered by default ​

NTFY_READ_ONLY defaults to false because ntfy exists to publish. The residual risk that follows is worth stating plainly: a prompt-injected client can publish what it has just read to another topic on the same instance. Confirmation tokens do not help there, because publishing is not a destructive operation. NTFY_TOPICS is the control that does, and an ntfy account scoped to those topics is the control behind it.

The confirmation, honestly ​

Five tools ask a person before they act: delete_messages, delete_user, manage_user_access, create_user and update_message.

Where the MCP client supports elicitation, the question is a dialog shown to whoever is sitting there. The model cannot answer it on their behalf, and nothing happens until an answer comes back.

create_user is the odd one on that list, because it destroys nothing. It is the mirror image of delete_user: bringing an account into existence is a change to who may reach this instance, and no annotation carries that — destructiveHint is about what a call takes away.

update_message is on it because of what ntfy ≥ 2.16 does with it. The revision replaces the notification on the subscribers' devices, so the text they were shown survives nowhere but this server's cache, and the tool carries the whole content schema — including actions, where an http button fires from the recipient's phone with a method, headers and body the caller chose. The argument that leaves publishing unguarded covers publishing and stops there.

Where the client cannot show a dialog, the tool falls back to a short-lived, single-use token. Be clear about what that proves, because this server is: the call was made twice with the same arguments, and nothing more. A model can read the token 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 fingerprint of the exact target. One issued for one target cannot execute another, cannot execute a longer list of message ids the model chose afterwards, and — for manage_user_access, whose three arguments overlap in vocabulary — cannot execute the same three values in a different order.

Confirmation prompts never quote content that came from ntfy. They name the topic, the count or the username and nothing else, because that text is read by a model; values the caller chose are shown on their own labelled lines rather than interpolated into the server's sentence. create_user's prompt also leaves out the password: it is a live credential, and the prompt is read back both by a person and by a model, so it is in neither the text nor the token's binding.

update_message binds the content it will write, not only the notification it will replace. The call carries the whole content schema — including actions, and an http action button fires from the recipient's device — so a confirmation obtained for a corrected typo cannot be redeemed for a call that adds one.

An approval proves freshness as well as binding. The sealed state carries a nonce and verifying it spends that nonce, so the same answer cannot be submitted twice — a retried leg or a gateway that re-sends is refused rather than executed again. The record of what has been spent lives in this process, so a restart forgets it; a stdio server is spawned per session, which is why that is the right lifetime. Every guarded tool here is idempotent in effect anyway, so even the forgotten case lands on the same instance. The one operation that genuinely acts twice is publish_message, which is unguarded, and ntfy has no idempotency key that would change it.

See Asking a person for what the dialog contains, which clients show one, and what ELICITATION=false does and does not change.

Untrusted content ​

Everything in a notification was written by whoever could publish to the topic, so list_messages, get_message, get_account and list_users frame their results as untrusted data rather than as this server speaking. Usernames and grant patterns are included in that: on an instance with signup enabled, anyone on the internet chooses their own username.

get_server_info is the exception, and deliberately so — its four sections are the instance's own configuration and counters, set by whoever runs the server this client was pointed at, and half of the object is derived locally rather than fetched.

The framing is not the only thing done to that text. Every string this server reports from ntfy is stripped of control characters — an escape sequence in a notification title is a terminal control sequence in the host's log file, and it means nothing in any field here — and of lone surrogates, which survive JSON.stringify as an escape and then break whichever client has to encode the result to UTF-8. Tab, line feed and carriage return are kept: those are content.

Nothing this server sends is trusted for its shape either. ntfy's answers are read field by field rather than cast to the type they are expected to have, so a time that is a word, a tags that is a number or an id that is an integer costs that field or that entry — reported as absent, with a count — instead of the whole listing it appeared in.

get_account and list_users answer from an allowlist ​

GET /v1/account returns every access token of the account in plaintext (verified against ntfy 2.19.2). Each one is overwritten with (redacted) rather than omitted — a caller seeing no token key at all could reasonably read it as "this entry had none". No tool in this server creates, reads or exchanges a token either, because every such endpoint hands back a live credential that would then live in the conversation transcript.

The rest of that response is dropped rather than filtered. get_account reports the username, role, tier, limits, usage, language and the metadata of each token, and nothing else — not the phone numbers, not the billing block with its Stripe identifiers, and not the reservations and subscriptions arrays, each of which is a list of topic names. A denylist would have removed only what was known to be sensitive on the day it was written; an allowlist drops the field a newer or forked ntfy adds without an edit here.

list_users projects the four fields it is about for the same reason. This upstream release carries no password hash in the user record, but that is a property of ntfy 2.19.2 rather than of this server.

URLs must be http or https ​

click, icon, attach and every action-button URL are restricted to the http: and https: schemes. ntfy stores whatever it is given — 2.19.2 accepted javascript:alert(1) as a click value without comment — and the reason this matters more than in an ordinary fetch guard is that ntfy does not open these URLs. The recipient's phone does. A click or icon is handed to a mobile client and to the web app, and an http action button is an outbound request originating on the recipient's device with a caller-controlled method, headers and body.

The check is scheme-only, with no private-address rule, and that is the right shape: a request to a device on the recipient's own network is the intended use of an http action for home automation, and it never leaves that network.

The download name of an attachment is checked separately — no path separators, no traversal, no control characters — because it is a filename on somebody else's device.

Every publisher-controlled field is bounded ​

ntfy caps the message body at 4096 bytes but not the title or the tag list, so those are capped here. The body cap is enforced client-side too, and not out of politeness: an oversized body is treated by ntfy as an attachment upload, so the error that comes back is 40014 attachments not allowed, which says nothing about size and sends whoever reads it looking in the wrong place entirely. The count is in bytes — a .max(4096) on characters would pass 4096 emoji, about 16 kB on the wire.

Topic names are validated before they reach a URL path. ntfy's failure mode there is unusually bad: a path that does not match its topic pattern falls through to the static file handler, so GET /has.dot/json answers 200 with the web app's HTML. Without the check that page is what the model would have to interpret.

What is deliberately absent ​

  • Sending email or placing a phone call from a published message. ntfy supports both; a tool that mails an arbitrary address on model output is a spam relay driven by injectable content, and call places a real, billable call.
  • Creating, reading or exchanging an access token.
  • Streaming subscriptions (/sse, /ws, /raw).
  • Attachment upload — by URL only.

An absent tool cannot be called, which is the same reason NTFY_READ_ONLY=true does not register the write tools rather than refusing them at call time, and why NTFY_DENY_TOOLS cuts along the same line.

Reporting a vulnerability ​

Use private vulnerability reporting. Do not open a public issue for an unpatched vulnerability, and do not include real credentials, tokens, hostnames or private configuration in a report.

Released under the MIT License.