Janus

Janus: Enterprise Model Context Protocol (MCP) API Gateway & Portal

GitHub Repository View Releases (v0.9)

MCP API Gateway — Code, Structure & Security Review

Reviewer: Claude (automated deep review) Date: 2026-06-30 Scope: Full Go source (~4,400 LOC), deployment (Terraform/EKS), CI, Dockerfile Branch: main

Executive Summary

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+

Addendum — security capabilities now available

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.


CRITICAL findings

C1. Hardcoded fallback secrets (auth bypass by default)

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:

Fix: Remove defaults. Fail closed (log.Fatal) if JWT_SECRET/GATEWAY_TOKEN are empty or shorter than 32 bytes. Never ship a usable default.

C2. Hardcoded backdoor admin login

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.

C3. Seeded backdoor client token with wildcard scope

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.

C4. No authorization (RBAC) on portal admin APIs — privilege escalation

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.

C5. SSRF + downstream credential exfiltration via connection registration

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.

C6. Real secrets committed in Terraform

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.


HIGH findings

H1. Tokens accepted via URL query parameter

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.

H2. SSO/OIDC flow is not secure

pkg/portal/api.go:143-233

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.

H3. Cloud vault providers are non-functional stubs

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.

H4. No HTTP server timeouts (DoS / Slowloris)

main.go:110-114http.Server sets no ReadTimeout, ReadHeaderTimeout, WriteTimeout, or IdleTimeout. Slowloris and slow-body attacks can exhaust connections. Fix: set all four.

H5. No request body size limits (DoS)

All JSON handlers json.NewDecoder(r.Body).Decode(...) with no http.MaxBytesReader. A large body exhausts memory. Fix: wrap bodies with http.MaxBytesReader.

H6. No rate limiting / brute-force protection

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.

H7. /messages MCP endpoint trusts session-ID only

pkg/mcp/server.go:279-304ServeMessages 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.


MEDIUM findings

LOW / Code-quality findings

What’s done well (keep)


Prioritized remediation order

  1. Kill the backdoors & defaults (C1, C2, C3, C6) — fail-closed config, remove static login, stop seeding live tokens, rotate the committed Terraform secrets.
  2. Add authorization (C4) — role checks on every /api/* mutating route.
  3. Lock down egress (C5) — destination allowlist + private-range blocking + host-bound secrets.
  4. Harden the HTTP edge (H4, H5, H6, H1, H7, M1) — timeouts, body caps, rate limits, header-only tokens, per-message re-auth.
  5. Fix OIDC properly (H2) and implement or fail the cloud vaults (H3).
  6. Quality/CI (L1, L6) — align Go versions, add tests for auth/scope/portal.