Skip to content
Amoylab Unla Oauth Self Issued Token Authz

Amoylab Unla Oauth Self Issued Token Authz

hackmd.io • October 11, 2026

# CVE-2026-108865: AmoyLab Unla through 0.10.0 OAuth2 Authentication Bypass via /authorize

![status](

![CVE](

![type](

![CWE](

![CWE2](

![component](

![severity](

**Reported by:** hieuPenguinnn, [github.com/hieuPenguinnn](

**Project:** [AmoyLab/Unla]( a self-hosted MCP gateway, OAuth2 authorization server and MCP route gate

**Affected:** pinned commit `5da380b8d883165def1c0a19ec55025ef12db8a5` (reviewed as v0.10.0) and current `main` as of 2026-10-11. Earliest affected release not bisected.

**Class:** CWE-862 Missing Authorization, CWE-863 Incorrect Authorization

**Status:** Source-reviewed, static-only. Coordinated disclosure pending. No CVE assigned yet.

Unla is a self-hosted MCP gateway. When OAuth2 is enabled it runs an OAuth 2.0 authorization server, and an MCP route prefix can be set to authentication mode OAuth2 so that requests to that prefix require a bearer token. The authorization-code flow has no resource owner. The `/authorize` endpoint mints an authorization code after checking only the client and the redirect URI, both of which the caller supplies, and the stored code and the issued token carry a client ID and a scope but no user identity. The MCP gate for an OAuth2-mode prefix accepts any token that exists in the store and has not expired, without checking which client or prefix the token belongs to.

The result is that an anonymous network caller who can reach the OAuth endpoints can register a client at `POST /register`, obtain an authorization code at `POST /authorize` against its own redirect URI, exchange that code for an access token at `POST /token` using the credentials it just created, and present that token on any OAuth2-mode MCP prefix. No account, no victim interaction, and no consent step are involved. The trust boundary crossed is anonymous network caller to an MCP route that OAuth2 mode is configured to protect. The concrete impact is access to the MCP tools and upstream resources behind that prefix, bounded by what those tools expose. The preconditions are that OAuth2 is enabled and that the target prefix is configured in OAuth2 mode, which is the intended secure configuration of the feature rather than an operator error.

The chain has five links. OAuth routes are mounted with no authentication middleware, client registration returns credentials to any caller, the authorization endpoint issues a code with no resource owner or consent, the token endpoint exchanges the caller's own code and credentials for a token, and the MCP gate accepts any token that exists and has not expired. All line numbers are at the pinned commit `5da380b8d883165def1c0a19ec55025ef12db8a5`.

### 1. OAuth routes are mounted with no authentication middleware

`internal/core/server.go:187-205` mounts the OAuth endpoints inside the OAuth2-enabled branch. The group carries only an optional CORS middleware. No authentication middleware is attached.

// Only register OAuth routes if OAuth2 is configured

// Create OAuth group with optional CORS middleware

if cors := s.auth.GetOAuth2CORS(); cors != nil {

oauthCorsMiddleware := s.corsMiddleware(cors)

s.router.OPTIONS("/*path", oauthCorsMiddleware)

oauthGroup.GET("/.well-known/oauth-authorization-server", s.handleOAuthServerMetadata)

oauthGroup.GET("/authorize", s.handleOAuthAuthorize)

oauthGroup.POST("/authorize", s.handleOAuthAuthorize)

oauthGroup.POST("/token", s.handleOAuthToken)

oauthGroup.POST("/register", s.handleOAuthRegister)

oauthGroup.POST("/revoke", s.handleOAuthRevoke)

`/register`, `/authorize`, and `/token` are reachable by anyone who can reach the gateway's HTTP listener.

### 2. Client registration returns credentials to any caller

`handleOAuthRegister` (`internal/core/auth.go:93-102`) passes the request straight to the auth service.

func (s *Server) handleOAuthRegister(c *gin.Context) {

resp, err := s.auth.Register(c.Request.Context(), c.Request)

`Register` (`internal/auth/oauth.go:189-243`) decodes the body, requires only that one redirect URI is present, generates a client ID and secret, stores the client, and returns both credentials.

func (s *oauth) Register(ctx context.Context, r *http.Request) (*ClientRegistrationResponse, error) {

if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

// ... defaults for grant types, response types, auth method, scope ...

if err := s.store.CreateClient(ctx, client); err != nil {

No check decides who may register. The caller chooses the redirect URIs and receives a working client ID and secret.

### 3. The authorization endpoint mints a code with no resource owner or consent

`handleOAuthAuthorize` (`internal/core/auth.go:27-80`) handles both methods. The GET branch renders a static page. The POST branch copies the form fields into the request query, calls `s.auth.Authorize`, and redirects to the caller-supplied redirect URI with the code.

// internal/core/auth.go:36-79 (abridged)

// ... state, response_type, scope copied from the form ...

resp, err := s.auth.Authorize(c.Request.Context(), c.Request)

// For GET requests, render the authorization page

s.renderAuthorizationPage(c, clientID, redirectURI, state)

The page render sets no session and checks no identity (`internal/core/auth.go:19-25`).

func (s *Server) renderAuthorizationPage(c *gin.Context, clientName string, redirectURI string, state string) {

c.HTML(http.StatusOK, "authorize.html", gin.H{

`Authorize` (`internal/auth/oauth.go:86-147`) validates the request shape, the client, and the redirect URI, then stores a code. The stored `AuthorizationCode` has no user field.

// internal/auth/oauth.go:87-146 (abridged)

func (s *oauth) Authorize(ctx context.Context, r *http.Request) (*AuthorizationResponse, error) {

clientID := r.URL.Query().Get("client_id")

redirectURI := r.URL.Query().Get("redirect_uri")

responseType := r.URL.Query().Get("response_type")

// ... code_challenge, code_challenge_method ...

if clientID == "" || redirectURI == "" || responseType == "" {

return nil, errorx.ErrUnsupportedGrantType

client, err := s.store.GetClient(ctx, clientID)

if !isValidRedirectURI(redirectURI, client.RedirectURIs) {

expiresAt := time.Now().Add(10 * time.Minute).Unix()

CodeChallengeMethod: codeChallengeMethod,

if err := s.store.SaveAuthorizationCode(ctx, authCode); err != nil {

return &AuthorizationResponse{Code: code, State: state}, nil

The client is one the caller registered at link 2, and `isValidRedirectURI` matches the redirect URI against that same client's registered URIs. There is no step that authenticates a resource owner and no record of a consent decision. The authorization code is produced from values the caller controls.

### 4. Token exchange uses the caller's own client and code

`handleOAuthToken` (`internal/core/auth.go:82-91`) delegates to `s.auth.Token`. `Token` (`internal/auth/oauth.go:149-179`) checks the client secret with a plain equality comparison, then dispatches to the authorization-code grant.

func (s *oauth) Token(ctx context.Context, r *http.Request) (*TokenResponse, error) {

grantType := r.PostForm.Get("grant_type")

clientSecret := r.PostForm.Get("client_secret")

client, err := s.store.GetClient(ctx, clientID)

return s.handleAuthorizationCodeGrant(ctx, r, client)

return s.handleRefreshTokenGrant(ctx, r, client)

return nil, errorx.ErrUnsupportedGrantType

`handleAuthorizationCodeGrant` (`internal/auth/oauth.go:263-332`) confirms the code belongs to this client, confirms the redirect URI, verifies PKCE only when a challenge was stored, then issues a token. The issued `Token` carries a client ID and a scope but no user identity.

ExpiresAt: time.Now().Add(time.Duration(expiresIn) * time.Second).Unix(),

Both the client and the code are resources the caller created in the two links, so the exchange succeeds for the caller alone.

### 5. The MCP gate accepts any token that exists and has not expired

The root handler is registered as the no-route handler (`internal/core/server.go:223`). `handleRoot` (`internal/core/server.go:229-261`) extracts the prefix, reads its auth configuration, and when the mode is OAuth2 it calls `isValidAccessToken`.

if auth != nil && auth.Mode == cnst.AuthModeOAuth2 {

c.Header("WWW-Authenticate", `Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"`)

"error_description": "Missing or invalid access token",

`isValidAccessToken` (`internal/core/auth.go:125-138`) parses the bearer token and delegates to `ValidateToken`, returning whether the error is nil.

func (s *Server) isValidAccessToken(r *http.Request) bool {

if len(parts) != 2 || parts[0] != "Bearer" || parts[1] == "" {

err := s.auth.ValidateToken(r.Context(), parts[1])

`ValidateToken` (`internal/auth/oauth.go:434-450`) fetches the token from the store and checks expiry. It does not associate the token with a user, a tenant, an audience, a scope, or the prefix being requested.

// ValidateToken validates an access token

func (s *oauth) ValidateToken(ctx context.Context, token string) error {

tokenInfo, err := s.store.GetToken(ctx, token)

if tokenInfo.ExpiresAt >RG: register a client with an attacker chosen redirect URI

RG-->>AT: returns client id and client secret

AT->>AZ: authorize with that client id, its redirect URI and response type code

Note over AZ: no resource owner, no login, no consent recorded

AZ-->>AT: redirect to the attacker redirect URI carrying an authorization code

AT->>TK: exchange the code with the caller own client id and secret

TK-->>AT: returns an access token valid for one hour

AT->>GT: request the protected prefix with the bearer token

Note over GT: ValidateToken checks existence and expiry only

GT-->>AT: request is accepted and reaches the MCP tools

> Preconditions. OAuth2 is enabled on the gateway (`IsOAuth2Enabled` returns true) and the target MCP prefix is configured with authentication mode OAuth2. The OAuth endpoints and the protected prefix are network-reachable by the attacker. No account and no victim interaction are required. The request examples below are illustrative and were NOT executed against any deployment.

1. **Register a client.** Send an unexecuted request of the form `POST /register` with a JSON body containing `redirect_uris` set to a value the attacker controls, for example a loopback URL. `Register` requires only that at least one redirect URI is present, so the response returns a fresh `client_id` and `client_secret` (`internal/auth/oauth.go:189-243`).

2. **Obtain an authorization code.** Send an unexecuted `POST /authorize` with form fields `client_id` set to the value from step 1, `redirect_uri` set to the same registered URI, and `response_type` set to `code`. The handler calls `Authorize`, which validates the client and the redirect URI and stores a code bound to the attacker client (`internal/core/auth.go:36-74`, `internal/auth/oauth.go:86-147`). The response is a 302 redirect to the attacker redirect URI with `code` in the query. A PKCE `code_challenge` may be supplied at this step and the matching verifier kept for step 3, but PKCE is optional in this flow.

3. **Exchange the code for a token.** Send an unexecuted `POST /token` with form fields `grant_type=authorization_code`, the `client_id` and `client_secret` from step 1, the `code` from step 2, and the same `redirect_uri`. `Token` compares the client secret and dispatches to `handleAuthorizationCodeGrant`, which confirms the code belongs to this client and issues an access token with a one-hour lifetime (`internal/auth/oauth.go:149-179`, `:263-332`). The response contains `access_token`.

4. **Call the protected prefix.** Send an unexecuted request to the OAuth2-mode MCP prefix with the header `Authorization: Bearer `. `handleRoot` sees the OAuth2 mode and calls `isValidAccessToken`, which calls `ValidateToken`. The token exists and has not expired, so the gate returns success and the request proceeds to the MCP endpoint dispatch (`internal/core/server.go:249-261`, `internal/core/auth.go:125-138`, `internal/auth/oauth.go:434-450`).

5. **State change.** The attacker now holds a bearer token the gateway accepts on the protected prefix and reaches the MCP tools configured behind it, with no account on the gateway.

Static and source-level only. No gateway was built or run, no OAuth endpoint was called, and no deployment was contacted. Runtime validation of the end-to-end flow was not performed.

The real source was read at the pinned commit through read-only GitHub pages, and each link in the chain was confirmed at the exact lines cited below. The same `Authorize`, `Register`, and `ValidateToken` functions were re-checked on current `main` and behave identically.

Confirmed from source at `5da380b8d883165def1c0a19ec55025ef12db8a5`:

- OAuth routes mounted with only optional CORS middleware, no authentication: `internal/core/server.go:187-205`.

- Client registration reachable with no authentication and returning credentials: `internal/core/auth.go:93-102` and `internal/auth/oauth.go:189-243`.

- Authorization code issued with no resource owner or consent, stored code has no user field: `internal/core/auth.go:19-25`, `:27-80` and `internal/auth/oauth.go:86-147`.

- Token issued from the caller's own client and code, issued token has no user field: `internal/core/auth.go:82-91` and `internal/auth/oauth.go:149-179`, `:263-332`.

- MCP gate accepts any existing non-expired token: `internal/core/server.go:223`, `:249-261`, `internal/core/auth.go:125-138`, and `internal/auth/oauth.go:434-450`.

What was not demonstrated at runtime. That a live gateway returns the token at `POST /token` for a self-registered client and that the token is accepted on the protected prefix end to end are inferred from the source path, not observed. The one-hour token lifetime and the absence of any user binding are established from the storage fields and the gate code, not from a live token. A maintainer can confirm the end-to-end behavior with a local instance that has OAuth2 enabled and one prefix set to OAuth2 mode.

An anonymous network caller who can reach the OAuth endpoints can obtain a bearer token the MCP gate accepts on any OAuth2-mode prefix, with no account, no victim interaction, and no consent step. The access is to the MCP tools and upstream resources behind that prefix.

**OAuth2 mode configured on a prefix.** The attacker completes registration, authorization, and token exchange using only values it created, then calls the protected prefix with the resulting token. The gate allows it because `ValidateToken` checks only existence and expiry.

**Scope and multi-prefix exposure.** The issued token carries the attacker client's self-declared scope and is not bound to any prefix, so a single self-issued token is accepted on every prefix that uses OAuth2 mode. The token lifetime is one hour, and the refresh-token grant lets the attacker renew access.

**What this does NOT establish.** It does not bypass any identity layer placed in front of the gateway by the operator, because that control is external to this source and out of scope. It does not grant access beyond the MCP tools exposed on the protected prefix, and it does not by itself compromise upstream services beyond what those tools already allow. No remote code execution and no database authentication bypass is claimed. The preconditions are that OAuth2 is enabled and the prefix is in OAuth2 mode, and that the endpoints are reachable by the attacker.

**Where exactly:** `internal/auth/oauth.go:86-147` (`Authorize` issues a code with no resource owner), `internal/auth/oauth.go:263-332` (`handleAuthorizationCodeGrant` issues a token with no user field), and `internal/auth/oauth.go:434-450` plus `internal/core/server.go:249-261` (the gate accepts any existing non-expired token). A leading check on client and redirect URI cannot stand in for authenticating the resource owner, so the authorization endpoint must require an authenticated user and a recorded consent before a code is issued, the token must be bound to that user, and the gate must check the binding and scope against the requested prefix.

**Short-term (primary fix): require an authenticated resource owner and consent at the authorization endpoint.** Reject a `POST /authorize` that does not carry an authenticated session, and record the consenting user on the authorization code.

@@ func (s *Server) handleOAuthAuthorize(c *gin.Context) {

+ // Require an authenticated resource owner before issuing a code.

+ c.Redirect(http.StatusFound, "/login?return_to="+url.QueryEscape(c.Request.URL.String()))

+ // Require an explicit consent field submitted from the authorize page.

+ if c.PostForm("consent") != "approve" {

+ s.sendOAuthError(c, errorx.ErrAccessDenied)

Carry the authenticated user into `Authorize` and store it on the code, then propagate it onto the issued token.

@@ authCode := &storage.AuthorizationCode{

ExpiresAt: time.Now().Add(time.Duration(expiresIn) * time.Second).Unix(),

**Medium-term: have the gate authorize the bound principal and scope against the prefix.** Change `ValidateToken` to return the token's user and scope, and have `handleRoot` confirm that the principal and scope are allowed on the requested prefix rather than only that the token exists.

@@ if auth != nil && auth.Mode == cnst.AuthModeOAuth2 {

+ principal, err := s.authorizeAccessToken(c.Request, prefix)

c.Header("WWW-Authenticate", `Bearer realm="OAuth", error="invalid_token"`)

c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})

**Defense in depth.** Restrict dynamic client registration to an authenticated context or an operator allowlist, restrict the registrable scope to a server-side allowlist rather than the client's self-declared value, and compare the client secret with a constant-time comparison at `internal/auth/oauth.go:167`. Add a regression test asserting that a self-registered client with no authenticated resource-owner session cannot obtain a token that the MCP gate accepts.

**Recommended:** authenticate the resource owner and record consent at the authorization endpoint, bind the authorization code and the issued token to that user, and have the MCP gate authorize the bound principal and scope against the requested prefix. The registration allowlist, the scope allowlist, the constant-time secret comparison, and the regression test are secondary hardening.

- Pinned source at commit `5da380b8d883165def1c0a19ec55025ef12db8a5`:

- [`internal/core/server.go` OAuth route registration](

- [`internal/core/server.go` handleRoot gate](

- [`internal/core/auth.go` authorize, register, token handlers and isValidAccessToken](

- [`internal/auth/oauth.go` Authorize](

- [`internal/auth/oauth.go` Token and Register](

- [`internal/auth/oauth.go` handleAuthorizationCodeGrant](

- [`internal/auth/oauth.go` ValidateToken](

- Release reviewed: [v0.10.0](

- Related issue: [AmoyLab/Unla issue #323]( discusses alternative authentication methods, not this self-registration and authorization path.

- Weaknesses: [CWE-862 Missing Authorization]( [CWE-863 Incorrect Authorization](

- No public advisory existed at the time of writing. The repository GitHub advisories page had no matching entry, and the repository has no `SECURITY.md` on `main`.

Reported by hieuPenguinnn, [github.com/hieuPenguinnn](

Extracted Entities