Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ NETBIRD_API_TOKEN=nb_pat_xxxxxxxxxxxxxxxxxxxx
# NetBird API base URL. Default is NetBird Cloud. Point this at your own host for self-hosted.
NETBIRD_API_URL=https://api.netbird.io

# Extra hosts a request-supplied NetBird base URL is allowed to target (comma-separated).
# The NETBIRD_API_URL host above is always allowed; add more here only if you accept
# per-request base URLs for several self-hosted instances.
NETBIRD_ALLOWED_API_HOSTS=

# Enable destructive tools (delete_peer, delete_policy, delete_group). Off by default in v1.
NETBIRD_ENABLE_DESTRUCTIVE=false

Expand All @@ -18,8 +23,15 @@ PUBLIC_BASE_URL=https://netbird-mcp.example.com
NETBIRD_ENABLE_OAUTH=true
# Verify the pasted NetBird PAT with a live read during the OAuth login. Set false for offline dev.
NETBIRD_VERIFY_PAT_ON_LOGIN=true
# Behind a reverse proxy / load balancer, set this so per-IP rate limits key on the
# real client IP, not the proxy's. Hop count (e.g. 1), boolean, or a preset like "loopback".
NETBIRD_TRUST_PROXY=

# Direct-PAT fallback (handy for testing; Claude itself uses OAuth above).
# OFF by default while OAuth is on — enable it explicitly here. It turns on
# automatically when NETBIRD_ENABLE_OAUTH=false (the only auth path then).
# When on, the request base URL must be allowlisted and the PAT is verified.
NETBIRD_ENABLE_DIRECT_PAT=false
Comment on lines 30 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Explicit false here defeats the auto-on-when-OAuth-off behavior it documents.

NETBIRD_ENABLE_DIRECT_PAT=false is set to a literal value, not left blank. In config.ts, boolEnv(env.NETBIRD_ENABLE_DIRECT_PAT, !oauthEnabled) only applies the !oauthEnabled fallback when the variable is undefined. Copying this template to .env unmodified and then setting NETBIRD_ENABLE_OAUTH=false will not auto-enable direct-PAT as the comment above promises — the operator is left with no HTTP auth path unless they also remember to flip this line.

🩹 Proposed fix
-NETBIRD_ENABLE_DIRECT_PAT=false
+NETBIRD_ENABLE_DIRECT_PAT=
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 30 - 34, Update the NETBIRD_ENABLE_DIRECT_PAT
entry in the environment template so it remains unset by default, allowing
config.ts to apply the !oauthEnabled fallback when NETBIRD_ENABLE_OAUTH=false.
Preserve the surrounding comments documenting explicit opt-in and OAuth-disabled
behavior.

# Header the hosted server reads the caller's NetBird PAT from (per-request, never stored).
NETBIRD_TOKEN_HEADER=x-netbird-token
# Optional header for per-tenant API base URL override.
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,24 @@ The flow implemented: OAuth 2.0 Protected Resource + Authorization Server metada
> The prototype keeps OAuth token→PAT bindings **in memory**. For production, back them with a
> shared, encrypted store (e.g. Redis) so tokens survive restarts and work across replicas.

> Behind a reverse proxy or load balancer, set `NETBIRD_TRUST_PROXY` (see Configuration) so the
> per-IP rate limits — the OAuth routes, the `/oauth/netbird-login` form, and per-source login
> PAT verification — key on the real client IP rather than the proxy's. Left unset behind a proxy,
> every client collapses into one bucket, which throttles everyone instead of isolating abusers.

### Direct-PAT alternative (testing / simple deploys)

Instead of OAuth you can pass the NetBird PAT directly per request via the `x-netbird-token`
header (configurable with `NETBIRD_TOKEN_HEADER`) or `Authorization: Token <pat>`. The token is
used only for the life of the request and never persisted, so one deployment still serves many
tenants. `Authorization: Bearer` is reserved for OAuth. Disable OAuth entirely with
`NETBIRD_ENABLE_OAUTH=false`.
tenants. `Authorization: Bearer` is reserved for OAuth.

This path is **off by default whenever OAuth is enabled** — turn it on explicitly with
`NETBIRD_ENABLE_DIRECT_PAT=true` (it is on automatically when OAuth is disabled, since it is then
the only way to authenticate). When it is on, each request's base URL must be on the host
allowlist — the configured `NETBIRD_API_URL` host plus anything in `NETBIRD_ALLOWED_API_HOSTS` —
and the presented PAT is verified against NetBird before any tool runs, so the server can't be
steered at an arbitrary host or driven with an unverified token.

---

Expand All @@ -117,14 +128,17 @@ tenants. `Authorization: Bearer` is reserved for OAuth. Disable OAuth entirely w
|----------|------|---------|---------|
| `NETBIRD_API_TOKEN` | local | — | NetBird PAT (required for stdio) |
| `NETBIRD_API_URL` | both | `https://api.netbird.io` | API base URL; set for self-hosted |
| `NETBIRD_ALLOWED_API_HOSTS` | both | (the `NETBIRD_API_URL` host) | Extra hosts a request-supplied base URL may target, comma-separated |
| `NETBIRD_ENABLE_DESTRUCTIVE` | both | `false` | Enable `delete_*` tools |
| `NETBIRD_MAX_RPM` | both | `110` | Client-side rate cap (under NetBird's 120/min) |
| `NETBIRD_TIMEOUT_MS` | both | `30000` | Per-request timeout |
| `LOG_LEVEL` | both | `info` | `debug` \| `info` \| `warn` \| `error` |
| `PORT` | cloud | `3000` | HTTP listen port |
| `PUBLIC_BASE_URL` | cloud | `http://localhost:PORT` | Public HTTPS origin advertised in OAuth metadata |
| `NETBIRD_ENABLE_OAUTH` | cloud | `true` | Enable the OAuth 2.1 authorization server |
| `NETBIRD_ENABLE_DIRECT_PAT` | cloud | off when OAuth on | Allow the direct-PAT header path (auto-on when OAuth is off) |
| `NETBIRD_VERIFY_PAT_ON_LOGIN` | cloud | `true` | Live-check the PAT during OAuth login |
| `NETBIRD_TRUST_PROXY` | cloud | `false` | Express `trust proxy` for real client IPs behind a proxy: hop count (e.g. `1`), boolean, or preset (`loopback`) |
| `NETBIRD_TOKEN_HEADER` | cloud | `x-netbird-token` | Header carrying the caller's PAT (direct-PAT mode) |
| `NETBIRD_URL_HEADER` | cloud | `x-netbird-api-url` | Optional per-tenant base URL header |

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"express": "^4.21.2",
"express-rate-limit": "^8.2.1",
"zod": "^3.23.8"
},
"devDependencies": {
Expand Down
12 changes: 11 additions & 1 deletion src/auth/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ export interface AuthContext {
}

/** Machine-readable reason for an AuthError, for transports to map uniformly. */
export type AuthErrorCode = "missing_credentials" | "unknown_token" | "wrong_scheme" | "oauth_disabled";
export type AuthErrorCode =
| "missing_credentials"
| "unknown_token"
| "wrong_scheme"
| "oauth_disabled"
/** The direct-PAT header path is turned off (NETBIRD_ENABLE_DIRECT_PAT). */
| "direct_pat_disabled"
/** The caller-supplied NetBird base URL is not on the host allowlist. */
| "forbidden_host"
/** The presented direct PAT was rejected by NetBird (401/403). */
| "invalid_token";

export class AuthError extends Error {
readonly code: AuthErrorCode;
Expand Down
68 changes: 58 additions & 10 deletions src/auth/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,51 @@
import type { IncomingHttpHeaders } from "node:http";
import type { NetBirdOAuthProvider } from "../oauth/provider.js";
import type { TokenVerification } from "../netbird/client.js";
import { checkApiUrl } from "../netbird/apiUrlPolicy.js";
import { authFromRequest, headerValue, RequestAuthOptions } from "./fromRequest.js";
import { AuthContext, AuthError } from "./context.js";

export interface AuthResolutionOptions extends RequestAuthOptions {
/** Pass only when OAuth is enabled; Bearer tokens then resolve through it. */
provider?: NetBirdOAuthProvider;
/**
* Hosts the caller-supplied base URL may target — the resolved
* config.allowedApiHosts. The direct-PAT path is checked against this before
* any outbound NetBird call, so a request can never steer the server at an
* arbitrary host.
*/
allowedApiHosts: readonly string[];
/**
* Whether the direct-PAT header path is available at all
* (config.http.directPatEnabled). Off by default when OAuth is enabled.
*/
directPatEnabled: boolean;
/**
* Verify a direct PAT against NetBird before it is trusted. "invalid" (401/403)
* is rejected; "unknown" (timeout / network / 5xx) is allowed through — the host
* is already allowlisted and NetBird refuses a bogus token on the real call, so
* an indeterminate check must not lock out a legitimate token.
*/
verifyPat: (auth: AuthContext) => Promise<TokenVerification>;
}

const BEARER_RE = /^Bearer\s+/i;

/**
* Resolve a request to a NetBird AuthContext, or throw AuthError. The single
* place that decides between the two ways a request can authenticate:
* Resolve a request to a trustworthy NetBird AuthContext, or throw AuthError.
* The single place that decides how a request authenticates:
*
* 1. OAuth 2.1 (what Claude uses): `Authorization: Bearer <token>`, resolved
* through the OAuth provider's typed binding lookup.
* 2. Direct PAT (handy for testing / simple deploys): the dedicated token
* header or `Authorization: Token <pat>`, resolved by authFromRequest.
* through the provider's typed binding lookup. The bound credential was
* verified when the user logged in, so nothing more is checked here.
* 2. Direct PAT (a fallback for testing / simple deploys): the dedicated token
* header or `Authorization: Token <pat>`. This path is only taken when it is
* explicitly enabled, and before the credential is trusted its base URL is
* checked against the host allowlist and the PAT is verified against NetBird.
*
* Bearer is reserved for OAuth. When no provider is supplied (OAuth disabled)
* a Bearer token falls through to the direct-PAT path, which rejects it —
* same rejection a caller with no credentials at all would see, just tagged
* with the more specific "oauth_disabled" reason.
* Bearer is reserved for OAuth: with no provider it falls through to the
* direct-PAT path, which rejects it — tagged "oauth_disabled" so the reason is
* specific.
*/
export async function resolveAuth(
headers: IncomingHttpHeaders,
Expand All @@ -31,17 +54,42 @@ export async function resolveAuth(
const authz = headerValue(headers, "authorization");
const bearerAttempted = authz !== undefined && BEARER_RE.test(authz);

// 1. OAuth path — the bound credential was already verified at login.
if (bearerAttempted && opts.provider) {
const token = authz!.replace(BEARER_RE, "").trim();
return opts.provider.resolveBinding(token);
}

// 2. Direct-PAT path is opt-in; when it's off, refuse before touching headers.
if (!opts.directPatEnabled) {
throw new AuthError(
bearerAttempted
? "OAuth is not available and direct-PAT authentication is disabled on this server."
: "Direct-PAT authentication is disabled on this server. Use the OAuth flow.",
bearerAttempted ? "oauth_disabled" : "direct_pat_disabled",
);
}

let ctx: AuthContext;
try {
return authFromRequest(headers, opts);
ctx = authFromRequest(headers, opts);
} catch (err) {
if (bearerAttempted && err instanceof AuthError) {
throw new AuthError(err.message, "oauth_disabled");
}
throw err;
}

// 3. Gate the caller-supplied base URL before any outbound request is made.
const urlCheck = checkApiUrl(ctx.baseUrl, opts.allowedApiHosts);
if (!urlCheck.allowed) {
throw new AuthError(`NetBird API URL rejected: ${urlCheck.reason}.`, "forbidden_host");
}

// 4. Verify the PAT before trusting the caller and building anything downstream.
if ((await opts.verifyPat(ctx)) === "invalid") {
throw new AuthError("The NetBird token was rejected (401/403).", "invalid_token");
}

return ctx;
}
49 changes: 44 additions & 5 deletions src/bin/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
} from "@modelcontextprotocol/sdk/server/auth/router.js";
import { resolveAuth } from "../auth/resolve.js";
import { AuthContext, AuthError } from "../auth/context.js";
import { loadServerConfig } from "../config.js";
import { loadConfigOrExit } from "./loadConfigOrExit.js";
import { createLogger } from "../logger.js";
import { LimiterPool } from "../netbird/limiterPool.js";
import { verifyPat, type TokenVerification } from "../netbird/client.js";
import { buildServer } from "../server.js";
import { NetBirdOAuthProvider } from "../oauth/provider.js";
import { loginRateLimiter } from "../oauth/loginRateLimit.js";

/**
* CLOUD entrypoint. Speaks MCP over Streamable HTTP so the server can be hosted
Expand All @@ -23,23 +25,55 @@ import { NetBirdOAuthProvider } from "../oauth/provider.js";
* Stateless: each request gets a fresh server instance, so one deployment safely
* serves many tenants and scales horizontally.
*/
const config = loadServerConfig();
const config = loadConfigOrExit();
const logger = createLogger(config.logLevel);
const { port, tokenHeader, urlHeader, oauthEnabled, publicBaseUrl, verifyPatOnLogin } =
const { port, tokenHeader, urlHeader, oauthEnabled, directPatEnabled, publicBaseUrl, verifyPatOnLogin, trustProxy } =
config.http;

const provider = new NetBirdOAuthProvider({
logger,
verifyPatOnLogin,
maxRequestsPerMinute: config.maxRequestsPerMinute,
requestTimeoutMs: config.requestTimeoutMs,
allowedApiHosts: config.allowedApiHosts,
});

// One rate limiter per tenant (keyed by a hash of the NetBird token, never the token
// itself), so NetBird's per-account limit is respected without cross-tenant interference.
const limiters = new LimiterPool(config.maxRequestsPerMinute);

// Verify a direct PAT against NetBird before it is trusted, reusing the tenant's
// own limiter so verification counts against the same per-account budget as its
// tool calls. Only the direct-PAT path uses this; OAuth bindings are pre-verified.
// An indeterminate result (or a surprise throw) fails open — the host is already
// allowlisted and NetBird refuses a bogus token on the real call — but is logged,
// so admitting a token without a clean verification is never silent.
const verifyDirectPat = async (auth: AuthContext): Promise<TokenVerification> => {
try {
const result = await verifyPat(auth, {
logger,
rateLimiter: limiters.get(auth.token),
timeoutMs: config.requestTimeoutMs,
});
if (result === "unknown") {
logger.warn("direct-PAT verification indeterminate; admitting token unverified", {
baseUrl: auth.baseUrl,
});
}
return result;
} catch (err) {
logger.warn("direct-PAT verification errored; admitting token unverified", {
baseUrl: auth.baseUrl,
message: (err as Error).message,
});
return "unknown";
}
};

const app = express();
// Behind a proxy this must be set so req.ip is the real client IP, which every
// per-IP rate limiter (OAuth routes, login form, per-source login verify) keys on.
app.set("trust proxy", trustProxy);
Comment on lines 73 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -B2 -A15 'trustProxy|TRUST_PROXY' src/config.ts .env.example README.md 2>/dev/null

Repository: netbirdio/netbird-mcp

Length of output: 5701


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the trust-proxy parser and its references without executing repo code.
fd -a 'config\.ts$' . | sed 's#^\./##'
rg -n -B5 -A60 'parseTrustProxy|trustProxy' src/config.ts src/bin/http.ts
sed -n '1,220p' src/config.ts
sed -n '60,85p' src/bin/http.ts

Repository: netbirdio/netbird-mcp

Length of output: 21936


Restrict NETBIRD_TRUST_PROXY to non-permissive values.

parseTrustProxy() accepts true, yes, and on, and loadConfigOrExit() forwards that value directly to app.set("trust proxy", trustProxy). Any operator typo such as NETBIRD_TRUST_PROXY=on makes Express trust the leftmost X-Forwarded-For, which can let an attacker bypass the IP-keyed rate limits. Reject bare boolean/yes-on values there, or document the env as accepted risk before passing it through.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bin/http.ts` around lines 73 - 76, Restrict the trust-proxy configuration
used by loadConfigOrExit and app.set("trust proxy", trustProxy) to explicit
non-permissive values, rejecting bare true, yes, or on inputs from
NETBIRD_TRUST_PROXY. Preserve valid proxy configuration behavior while ensuring
permissive aliases cannot enable trusting attacker-controlled X-Forwarded-For
values.

app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: false })); // OAuth login form posts

Expand All @@ -61,8 +95,10 @@ if (oauthEnabled) {
resourceName: "NetBird",
}),
);
// Our interactive login step (the page /authorize renders posts here).
app.post("/oauth/netbird-login", provider.handleLogin);
// Our interactive login step (the page /authorize renders posts here). Rate
// limited like the SDK's own auth routes, since each submission triggers an
// outbound PAT verification.
app.post("/oauth/netbird-login", loginRateLimiter(), provider.handleLogin);
}

app.post("/mcp", async (req, res) => {
Expand All @@ -72,6 +108,9 @@ app.post("/mcp", async (req, res) => {
provider: oauthEnabled ? provider : undefined,
tokenHeader,
urlHeader,
allowedApiHosts: config.allowedApiHosts,
directPatEnabled,
verifyPat: verifyDirectPat,
});
} catch (err) {
if (err instanceof AuthError) {
Expand Down
17 changes: 17 additions & 0 deletions src/bin/loadConfigOrExit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { loadServerConfig, type ServerConfig } from "../config.js";

/**
* Load config for an entrypoint, or fail fast. loadServerConfig throws on an
* unusable NETBIRD_API_URL, and no logger exists this early in boot, so surface
* the reason as a clean one-line fatal on stderr and refuse to start. Shared by
* both entrypoints so the boot-failure behaviour has one implementation.
*/
export function loadConfigOrExit(): ServerConfig {
try {
return loadServerConfig();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`fatal: ${message}\n`);
process.exit(1);
}
}
4 changes: 2 additions & 2 deletions src/bin/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { authFromEnv } from "../auth/fromEnv.js";
import { AuthError } from "../auth/context.js";
import { loadServerConfig } from "../config.js";
import { loadConfigOrExit } from "./loadConfigOrExit.js";
import { createLogger } from "../logger.js";
import { buildServer, SERVER_NAME, SERVER_VERSION } from "../server.js";

Expand All @@ -11,7 +11,7 @@ import { buildServer, SERVER_NAME, SERVER_VERSION } from "../server.js";
* speaks MCP over stdio. Credentials come from the environment (single tenant).
*/
async function main(): Promise<void> {
const config = loadServerConfig();
const config = loadConfigOrExit();
const logger = createLogger(config.logLevel);

let auth;
Expand Down
Loading
Loading