Plugin security
What a plugin can reach, what it must declare, and where the boundaries actually are.
Plugin security
Plugins are trusted code
A Nextly plugin runs in your application's own process, with your database connection and your file system. It is not sandboxed, and nothing in this page should be read as though it were.
That is worth saying plainly, because the features below — declared
capabilities, a restricted fetch, an allowlisted audit surface — look like a
sandbox and are not one. A plugin that wanted to bypass any of them could
import Node's modules directly, and nothing would stop it.
Installing a plugin is the security decision. Treat it the way you would treat adding a dependency that runs on your server, because that is what it is.
What the manifest is for, then
A manifest makes a plugin's reach legible before you install it. Instead of reading its source to find out whether it calls an external service, you read what it declared:
import { definePlugin } from "@nextlyhq/plugin-sdk";
import { z } from "zod";
export default definePlugin({
name: "@acme/auth",
version: "1.0.0",
nextly: ">=0.0.2-alpha.63",
contributes: {
settings: z.object({
providers: z
.record(z.string(), z.object({ clientId: z.string(), clientSecret: z.string() }))
.default({}),
}),
},
capabilities: {
net: { outbound: ["accounts.google.com", "*.googleapis.com"] },
secrets: ["providers.*.clientSecret"],
},
provides: ["acme/auth-provider"],
requires: { "acme/webhook-signing": ">=2.0.0" },
});The runtime holds the plugin to what it declared, for the things it can:
ctx.fetch exists only with declared hosts and reaches only those, the
settings store encrypts exactly those keys, ctx.db has no execute or run
without rawSql, and ctx.auth.completeLogin refuses a plugin that did not
declare auth.login. An invalid manifest fails boot with the plugin named,
rather than becoming a surface that quietly does not exist: an unknown key
anywhere inside capabilities, a value of the wrong type, an outbound entry
that is not a hostname, an empty or unparseable requires range, secrets
without a settings schema, and a secret path that is unknown, repeated, names
a group rather than one value, or names a value that cannot be a string, such
as a number or a boolean (an unknown path is caught only where the schema can
be walked; see Secrets).
nextly plugins info <name> prints a plugin's manifest: the hosts it may
reach, whether it declares raw SQL, whether it finishes logins, its secret
paths, what it provides and requires, and its schemaVersion.
Capability names are global, and two plugins cannot provide the same one, so
prefix them with your vendor (acme/auth-provider). requires is checked at
boot; it does not change the order plugins initialise in, which dependsOn
decides.
Outbound requests
ctx.fetch exists only for a plugin that declared a non-empty
capabilities.net.outbound.
A hostname allowlist on its own does not prevent server-side request forgery: a name the plugin declared can resolve to an address inside your network, and can resolve differently on the second lookup than it did on the check. So the decision is made about the resolved address, and the request is sent to the address that was vetted rather than to the name again.
Refused by construction:
- anything that is not
https:(whenNODE_ENVisdevelopmentortest,http:tolocalhoston any port is allowed iflocalhostis listed incapabilities.net.outbound, so a fake provider can be used in tests; withNODE_ENVunset or any other value it is refused); - a host not on the allowlist, matched on whole labels —
*.example.comcoversa.example.comand notexample.com.evil.com; - a port the entry does not name:
api.example.comallows only the scheme's default port, andapi.example.com:8443allows that port instead; - private, loopback, link-local, carrier-NAT, benchmark, documentation
(TEST-NET), multicast and reserved addresses, including
169.254.169.254, which on most cloud providers answers with the instance's own credentials; - the IPv6 ways of writing those — IPv4-mapped, IPv4-compatible, NAT64 and 6to4 are each judged as the IPv4 address they carry;
- a name that answers with one public address and one internal one;
- a redirect to anything the rules above refuse — each hop is re-checked;
- a redirect that would send the request body to a different origin (a 307 or 308, or a 301 or 302 that keeps its method). A body can carry a credential as surely as a header, so it never crosses an origin; send the request again to the new address if that is what you meant;
- the
CONNECT,TRACEandTRACKmethods, theUpgrade,Keep-AliveandExpectrequest headers, and aConnectionheader other thancloseorkeep-alive(those two are dropped, since the transport manages its own connection); an answer that switches protocols (101) is refused rather than awaited; - a request body over 10 MB, more than three redirects, a response over 10 MB, or 30 seconds.
ctx.fetch connects directly. It does not use an egress proxy, and ignores
HTTP_PROXY, HTTPS_PROXY and NO_PROXY: a proxy would connect to the name
again, and the address it reached would not be the one that was vetted.
Route protections
A plugin route can ask for the same protections core's own routes have:
{
method: "POST",
path: "/callback",
public: true, // a webhook signs its body; it carries no session
rateLimit: "auth", // core's auth limit, counted per route and client
rawBody: true, // declares a signed body; the bytes always arrive untouched
noStore: true, // never cached
}Two things are worth knowing. By default, an authenticated route checks the
origin of every unsafe request from a session-cookie caller: it must come
from your site or from an origin listed in NEXTLY_ALLOWED_ORIGINS. Browsers
send Origin (or Referer) on cross-site writes, so a forged request is
refused, while a same-origin write passes without sending a token. This is
the origin half of what core's /auth/* routes check. Declare
csrf: true to require a double-submit CSRF token as well, the protection
core's /auth/* routes take. Only cookie-authenticated callers are
checked: a browser cannot attach an API key cross-site, so there is no
cross-site request to forge, and API-key and webhook callers need no opt-out.
csrf: false makes a route refuse every unsafe request the session cookie
authenticates, from any origin, so it takes writes only from callers with
another credential. A refusal answers
403 with the code CSRF_FAILED and records a csrf-failed audit event. And
rateLimit: "auth" uses core's limit and window in buckets of the plugin's
own, so one plugin's traffic cannot exhaust the budget protecting another's —
or core's login. Each route path and each client has its own bucket, with an
IPv6 client counted by its /64. The client is known only with
security.trustProxy on: without it no client address is read, so every
client shares one bucket per route, and boot names each rate-limited route
when that is the case.
A route your plugin's admin page writes to works with csrf unset or true
when the page writes through usePluginRouteMutation (from
@nextlyhq/plugin-sdk/admin): it sends x-csrf-token on every write, read
from the nextly_csrf cookie or fetched once from /admin/api/auth/csrf. A
page that writes with its own fetch either leaves csrf unset, or fetches
/admin/api/auth/csrf and sends the token as x-csrf-token (or as
csrfToken in a JSON body); with csrf: true and no token, every save
answers 403.
A public route skips the default check — it authenticated no one, and a cookie
on the request says nothing about what admitted it. A public handler that
resolves the session user through ctx.auth.currentUser is checked there
instead: a POST, PUT, PATCH or DELETE whose origin is not your site or an
allowed one, or that names no origin, gets no user (null). Declare
csrf: true to require the token as well; only callers carrying the session
cookie are asked for it, so webhook callers and browsers with only unrelated
cookies stay free. csrf: false cannot be combined with public: true: boot refuses
such a route, naming the plugin and the route.
ctx.auth.verifyCsrf remains the tool when the check must stay conditional
inside the handler instead.
Secrets
Settings keys named in capabilities.secrets are encrypted at rest with a key
derived from your NEXTLY_SECRET, bound to the plugin and the path they are
stored at, so a value copied to another plugin or path does not decrypt.
Each value names the secret generation that encrypted it. After a rotation,
keep the old secret in NEXTLY_SECRET_PREVIOUS: values encrypted under it are
still read, and are re-encrypted under the new secret the next time they are
read. A value no configured secret can decrypt is shown in the admin as set but
unreadable, { set: true, readable: false }, and ctx.settings.get() refuses
until it is entered again; saving a new value replaces it.
A secret path may be nested and may use * to cover every key of a record:
providers.*.clientSecret encrypts each provider's secret while leaving its
client id readable in the same stored value.
A secret is never returned to the browser. The admin API answers with
{ set: true } or { set: false }, which is what an operator needs in order to
decide whether to replace it. A path declared as a secret that the settings
schema does not contain fails boot, because silently storing a credential in
plain text is the worst way for that mistake to land. The check can only
follow a schema it can walk: under z.any(), z.unknown(), a pipe (such as
.transform()) or z.lazy(), a path is accepted as written, and a misspelt
one there matches nothing, so the value it meant is stored in plain text.
Spell such a path exactly.
The audit trail
ctx.audit.write records only kinds the plugin declared, namespaced under its
own slug, so one plugin cannot write rows that read as another's — or as core's.
A kind is <slug>. followed by a lowercase letter or digit, then any of
lowercase letters, digits, ., _ and -.
Metadata keys are allowlisted per kind, strings longer than 256 characters are
not stored, and a value with one of these credential shapes is dropped: a JWT,
a Bearer or Basic authorization value, a PEM block, Stripe secret,
restricted and webhook keys (sk_live_, sk_test_, rk_live_, rk_test_,
whsec_), GitHub, GitLab, Slack and
npm tokens, AWS access key ids, and Google OAuth access tokens (ya29.). That
is a list of known shapes, not a secret detector: never pass a credential.
The restriction exists because audit rows are retained and some of them deliberately name nobody, which means nothing links them to a person and no later deletion can find them. What can enter has to be bounded rather than left to each plugin.
Deleting a user clears the metadata of every plugin row that names them, as
actor or as target, while keeping the row itself: what happened, and when.
targetUserId must name an existing user; a row naming one that does not
exist is stored without its metadata, and a warning is logged.
Finishing a login
A plugin that authenticated someone elsewhere does not assemble a session
itself. ctx.auth.completeLogin applies the same account-state rules, hooks and
audit trail as a password login, so a plugin cannot grant a session core would
have refused. See plugin authentication.
A checklist before installing
- Read the manifest. Does what it declares match what the plugin says it does?
- Does it ask for outbound hosts you recognise?
- Does it finish logins (
capabilities.auth.login)? That plugin can sign in any account it names, so it should be a sign-in provider you chose. - Does it declare raw SQL (
capabilities.db.rawSql)? Most plugins should not. Either way, read the database access as total: without the capability the plugin still reads and writes every table through the fluent builder. Left out,ctx.dbhas onlyselect,insert,update,deleteandtransaction; declared, it is the live Drizzle instance,executeandrunincluded. The declaration tells you what the plugin means to do; it is not a boundary a determined plugin cannot cross, because plugins run as trusted code. - Does the source match the published package?
- Is it maintained by someone you would give a database credential to?
That last question is the real one. Everything above narrows what a mistake can reach; none of it narrows what a decision can.