Janus: Enterprise Model Context Protocol (MCP) API Gateway & Portal
Reviewer: Claude (automated deep review) Date: 2026-06-30 Scope: Full Go source (
~4,400 LOC), deployment (Terraform/EKS), CI, Dockerfile Branch:main
The project is a well-organized Go MCP gateway that proxies LLM tool calls to configured downstream APIs, with a JWT-protected admin portal, a pluggable vault, OpenTelemetry, and a distroless container. The engineering structure is good; the security posture is not production-ready. The dominant theme is authentication without authorization and insecure-by-default secrets. Several findings are individually Critical and compound: default secrets + no RBAC + admin-configurable proxy targets = full internal-network SSRF and downstream-credential exfiltration by any authenticated user.
Risk rating: HIGH — do not expose to untrusted networks until the Critical items are fixed.
| Severity | Count |
|---|---|
| Critical | 6 |
| High | 7 |
| Medium | 9 |
| Low / Quality | 10+ |
Since this review, the following defense-in-depth features have been added (all configurable; off by default unless noted). They augment — not replace — the existing master/client-token auth model.
OAUTH_ENABLED=true) — advertises protected-resource metadata at
GET /.well-known/oauth-protected-resource (RFC 9728), issues WWW-Authenticate challenges, and
validates audience-bound (RFC 8707) JWT access tokens against the configured authorization
servers’ JWKS (OAUTH_RESOURCE_URI, OAUTH_AUTHORIZATION_SERVERS, OAUTH_SCOPES_SUPPORTED).TOOL_PINNING_STRICT=true) — each tool exposes a SHA-256
definitionHash + version in tools/list; strict mode blocks calls to a tool whose definition
changed since approval (rug-pull / tool-poisoning defense).REDACTION_ENABLED=true) — masks emails, Luhn-validated cards,
JWTs, AWS keys, API keys, and IBANs in tool arguments and downstream responses before they reach
the LLM; redaction events are audit-logged as class + count only (never the value).local vault — the file-backed vault is now AES-256-GCM encrypted at rest
(see M5, addressed below).pkg/config/config.go:32,36
JWTSecret: getEnv("JWT_SECRET", "dev-jwt-secret-key-change-in-production"),
GatewayToken: getEnv("GATEWAY_TOKEN", "secure-mcp-gateway-token-123456"),
If the env vars are unset, the gateway runs with publicly known secrets. Anyone can:
master/admin MCP role with * scope.Fix: Remove defaults. Fail closed (log.Fatal) if JWT_SECRET/GATEWAY_TOKEN are empty
or shorter than 32 bytes. Never ship a usable default.
pkg/portal/api.go:129
if credentials.Username == "admin" && credentials.Password == "admin-gateway-secret" {
token, _ := p.authManager.GenerateJWT(credentials.Username, "admin")
A static username/password mints an admin JWT. There is no env override and no way to disable it. Combined with C1, this is a guaranteed remote admin takeover on any default deployment.
Fix: Remove. Source the bootstrap credential from a hashed secret (bcrypt/argon2) loaded from env/vault, or disable local login entirely when OIDC is configured.
main.go:404-415
tok := &storage.ClientToken{ Token: "lch_member_test_token_889", Scopes: "*", Enabled: true }
Every fresh database is seeded with a known token granting access to all MCP tools.
Fix: Never seed live credentials. Generate a random token at first boot, print once, or require explicit admin creation.
pkg/auth/auth.go:85 + pkg/portal/api.go:55-78
PortalAuthMiddleware validates only that the JWT is valid; it never checks claims.Role.
Every protected route (/api/connections, /api/vault, /api/tokens, /api/endpoints,
/api/settings) is therefore reachable by any authenticated principal — including an
SSO user issued role "user" (api.go:225). A low-privilege SSO user can create client
tokens, write vault secrets, and register proxy connections.
Fix: Add role enforcement in the middleware (or per-handler), e.g. require role == "admin"
for all /api/* mutating routes. Carry an authorization layer, not just authentication.
pkg/gateway/client.go:37-167, pkg/portal/api.go:250 (POST /api/connections),
pkg/mcp/server.go:580 (admin_add_connection)
An authenticated principal (and, given C1–C4, effectively an unauthenticated one) can register a
connection with an arbitrary base_url and an auth_secret_ref + bearer auth type. When the
tool is invoked, the gateway fetches the secret from the vault and sends it in the Authorization
header to the attacker-controlled URL → secret exfiltration. The same primitive allows SSRF to
internal services and the cloud metadata endpoint (http://169.254.169.254/...) from inside EKS.
There is no allowlist of destination hosts, no block on private/link-local ranges, and path
parameters are string-substituted into the URL (client.go:40-45) allowing path/host manipulation.
Fix: Enforce an egress allowlist (scheme https, approved hostnames). Resolve and reject
RFC-1918 / link-local / loopback targets. Never attach a secret to a request whose host is not the
secret’s bound host. Validate renderedPath cannot alter host/scheme.
deployment/secrets.tf:13-16
secret_string = jsonencode({
jwt-secret = "dev-jwt-session-secret-change-in-production-12345"
gateway-token = "dev-mcp-client-auth-token-67890"
})
These values are version-controlled and become the actual production secret values unless manually overwritten post-apply. They are now compromised by virtue of being in git history.
Fix: Use random_password resources or supply via TF_VAR/SOPS; mark sensitive = true;
add a lifecycle { ignore_changes = [secret_string] } pattern so real values aren’t clobbered.
Rotate these tokens.
pkg/auth/auth.go:99-101, pkg/mcp/server.go:193
JWTs and gateway tokens are accepted via ?token=. Query strings leak into access logs, browser
history, proxy logs, and Referer headers. Fix: Authorization header only; if a browser flow
needs it, use a short-lived cookie with HttpOnly/Secure/SameSite.
pkg/portal/api.go:143-233
state parameter → OAuth CSRF / login-CSRF.nonce, no PKCE.api.go:211)
and trusts preferred_username/email. No iss/aud/exp validation.redirect_uri is inconsistent: login builds it from the issuer host (api.go:150), callback
hardcodes http://localhost:PORT (api.go:171) — broken outside localhost and non-TLS./#token=... (api.go:232) — token in browser history.Fix: Use a vetted OIDC library (e.g. coreos/go-oidc), verify signatures and claims, add
state+PKCE, and return the session token via secure cookie.
pkg/vault/vault.go:114-169
AWSVault/GCPVault/AzureVault return hardcoded strings ("aws-secret-stub", etc.). Any
deployment with VAULT_PROVIDER=aws (the intended EKS mode) will inject the literal string
"aws-secret-stub" as the downstream credential — silently broken auth, and a false sense of
secret management. Fix: Implement real providers or fail loudly (return error) for
unimplemented providers instead of returning fake data.
main.go:110-114 — http.Server sets no ReadTimeout, ReadHeaderTimeout, WriteTimeout, or
IdleTimeout. Slowloris and slow-body attacks can exhaust connections. Fix: set all four.
All JSON handlers json.NewDecoder(r.Body).Decode(...) with no http.MaxBytesReader. A large body
exhausts memory. Fix: wrap bodies with http.MaxBytesReader.
grep confirms no limiter anywhere. /api/auth/login (C2 password), gateway-token validation, and
tool calls are all unthrottled. Fix: add per-IP/per-principal rate limiting and login backoff.
/messages MCP endpoint trusts session-ID onlypkg/mcp/server.go:279-304 — ServeMessages looks up the session purely by ?sessionId= (a UUID
in the URL) and re-runs no token check. Anyone who obtains the session ID (it travels in URLs/logs,
see H1) can drive tool calls as that session’s identity. Fix: bind the session to its auth token
and re-verify on each POST, or require the token on /messages too.
Access-Control-Allow-Origin: * (server.go:235) with token-based
auth; combine with H1 and any origin can connect. Scope to known origins.err via
fmt.Sprintf('{"error":"%v"}', err) (portal throughout) and JWT errors (auth.go:110). Leaks DB
driver/paths/internals. Return generic messages; log details server-side./metrics is unauthenticated (main.go:102) — exposes operational metrics publicly.
Bind to an internal port or require auth./api/settings info disclosure (api.go:416-426) — returns database path, vault paths,
OIDC client ID, cert paths to any authed user. Minimize.local vault now encrypts
its file with AES-256-GCM (key from VAULT_ENCRYPTION_KEY, falling back to JWT_SECRET), and
transparently migrates any pre-existing plaintext file to ciphertext on first read. It previously
stored secrets as plain JSON (0600).client_tokens.token is the raw bearer value and the
PK (storage/db.go). A DB read = all tokens. Store a hash; look up by hash.api.go:129, ==). Minor timing oracle (mooted by
removing C2, but note the pattern). VerifyGatewayToken correctly uses subtle — good.cluster_endpoint_public_access = true
(deployment/eks.tf) with no public_access_cidrs restriction. Restrict or disable public access.deploy.yml uses AWS_ACCESS_KEY_ID/SECRET. Prefer GitHub
OIDC (role-to-assume) for short-lived credentials. permissions: contents: read is good.go-version: '1.22' but go.mod declares
go 1.26.3 and the Dockerfile uses golang:1.26. CI builds/tests on an older toolchain than
production (and may fail outright). Align them.auth.go:38). Add refresh + a revocation list.aud/sub on issued JWTs; only issuer set. Add and validate audience.seedDatabase runs unconditionally in main and seeds external connections (Coinbase,
Treasury) + the backdoor token — demo behavior in the production entrypoint. Gate behind a
--seed flag or SEED=true.client.go:68-87 does raw string replacement into a JSON body
template; non-string params are json.Marshal‘d (good) but the placeholder (unquoted) branch can
still break JSON structure. Build the body via a real encoder.pkg/gateway/client_test.go (127 LOC). No tests for auth, scope
matching, SQL layer, or the portal handlers — exactly the security-critical paths.handleOperationalStats SSRF-ish health check (api.go:474) — client.Get(c.BaseURL) to
admin-configured URLs; bounded by C5’s fix.server.go (stdio + SSE) — extract a helper.db.query() rewrites ?→$n for Postgres but uses
SQLite-specific ON CONFLICT ... excluded and an ALTER TABLE ADD COLUMN migration hack
(db.go:154); brittle across drivers. Use a migration tool.k8s-janus.yaml referenced by deploy.yml via sed with a hardcoded ECR account ID
796973489124; fragile string-replace deploy. Use Kustomize/Helm image overrides.k8s/janus-gateway.yaml and the image is now set through a Kustomize images:
override (kustomize edit set image), not string replacement. The pipeline no longer applies to the
cluster at all: it commits the tag and FluxCD reconciles, so the deploy role’s EKS access is now
unused and can be dropped from its IAM policy.auth, config, gateway, mcp, portal, storage, telemetry,
vault) — good separation of concerns; the VaultProvider interface is a solid seam.storage/db.go).auth.go:57) — blocks alg=none/RS↔HS confusion.auth.go:79-81).auth.go:131-149).Dockerfile).secrets.tf) — least-privilege intent is right./api/* mutating route.