# CVE-2026-108718: Rill 0.77.0 through 0.90.5 OAuth Missing Authorization via Dynamic Client Registration
, Post and Telecommunication Institute of Technology, [github.com/Haind03](
**Project:** [rilldata/rill]( admin server OAuth authorization server for MCP clients
**Affected:** v0.90.5 (`b9ea8c6f215e56c4c0848771e70b3ec38346eacc`) and current `main` as of 2026-10-11. The register route mounting is identical on both. Earlier range not bisected.
**Class:** CWE-306 Missing Authentication for Critical Function, CWE-862 Missing Authorization, CWE-601 URL Redirection to Untrusted Site
**Status:** Report package prepared for coordinated disclosure via VulnCheck. Not yet submitted. No CVE assigned.
The Rill admin server runs an OAuth 2.0 authorization server for MCP clients. Its dynamic client registration endpoint `POST /auth/oauth/register` is mounted at `admin/server/auth/handlers.go:126` with only anonymous rate limiting and no authentication middleware, unlike the sibling `/auth/oauth/authorize` route in the same block which is wrapped with the authentication middleware. An unauthenticated actor registers a client whose `redirect_uris` point to any external host (the redirect validator checks only scheme, host presence, and the absence of a fragment) and whose `scope` includes the custom value `long_lived_access_token`. The registration handler stores these fields verbatim with no scope allowlist.
The authorize endpoint runs inside the victim's authenticated session. Its handler reads the victim user id from the session claims and, when a PKCE code challenge is present, calls `handlePKCE` which validates the redirect against the attacker-registered client and immediately issues an authorization code bound to the victim. No consent screen or approval step exists anywhere in that path. The token endpoint exchanges the code without a client secret (public client, PKCE S256), reads the scope from the attacker-registered client record, and because it contains `long_lived_access_token` calls `IssueUserAuthToken` with a nil TTL. The response carries `ExpiresIn: 0 // never expires`. `IssueUserAuthToken` stores a nil expiry and applies no server-side cap, and the validation path treats a nil expiry as never-expired. The admin auth middleware accepts that user token for AdminService RPCs as the victim across the victim's organizations and projects. One victim click on an attacker-crafted authorize link, while that victim is logged in, yields a never-expiring full-account API token.
The code at `admin/server/auth/pkce.go:177` describes long-lived tokens as issued "for clients explicitly whitelisted", but the only gate is the client's own self-declared `Scope` field, and client registration is unauthenticated.
The chain has seven links. An unauthenticated registration route stores an attacker-chosen redirect and scope, the authorize handler runs as the victim and issues a code with no consent, the token endpoint exchanges it with no client secret, the long-lived branch issues a token with a nil TTL, the issuer stores no expiry and caps nothing, and the middleware accepts the token as the victim.
### 1. The registration route carries no authentication
`admin/server/auth/handlers.go:126` mounts dynamic client registration with `checkLimit` only.
observability.MuxHandle(inner, "/auth/oauth/register",
middleware.Check(checkLimit("/auth/oauth/register"), http.HandlerFunc(a.handleOAuthRegister)))
`checkLimit` applies an anonymous rate limit and passes the request through. It does not require a session. The sibling authorize route in the same block is wrapped with the authentication middleware `a.HTTPMiddleware`, which is the guarded form.
// admin/server/auth/handlers.go (authorize route, same registration block)
observability.MuxHandle(inner, "/auth/oauth/authorize",
a.HTTPMiddleware(middleware.Check(checkLimit("/auth/oauth/authorize"), http.HandlerFunc(a.handleAuthorizeRequest))))
The register line has no `a.HTTPMiddleware`. Anyone on the network can register a client.
### 2. The redirect validator accepts any external host
`sanitizeRedirectURIs` (`admin/server/auth/mcp_oauth.go`, approx lines 174-205) enforces three conditions and nothing else.
// admin/server/auth/mcp_oauth.go (sanitizeRedirectURIs, approx lines 174-205)
if parsed.Scheme != "https" && parsed.Scheme != "http" {
// no allowlist, no first-party host check, no loopback restriction
Scheme must be `http` or `https`, the host must be non-empty, and there must be no URL fragment. A host the attacker controls, for example an external collector, passes all three.
### 3. Registration stores the attacker scope and grants verbatim
`handleOAuthRegister` (`admin/server/auth/mcp_oauth.go`, approx lines 77-153) normalizes whitespace and stores the record.
// admin/server/auth/mcp_oauth.go (handleOAuthRegister, approx lines 77-153)
scope := sanitizeScope(req.Scope) // whitespace normalization only
grantTypes := sanitizeGrantTypes(req.GrantTypes) // trim only
redirectURIs, err := sanitizeRedirectURIs(req.RedirectURIs)
client, err := a.admin.DB.InsertAuthClient(r.Context(), displayName, scope, grantTypes, redirectURIs)
`sanitizeScope` only collapses whitespace. There is no allowlist on the scope value. A client that asks for `long_lived_access_token` is stored with that scope. The authorization-server metadata advertises only `offline_access`, but the registration handler does not restrict registration to that set.
### 4. The authorize handler runs as the victim and issues a code with no consent
`/auth/oauth/authorize` executes inside the victim session because of the authentication middleware. The handler reads the victim identity and calls `handlePKCE`.
func (a *Authenticator) handleAuthorizeRequest(w http.ResponseWriter, r *http.Request) {
// anonymous callers are redirected to /auth/login?redirect= ,
// then the flow returns to this handler after login
// ... request-parameter validation only, no consent or approval screen ...
a.handlePKCE(w, r, clientID, userID, codeChallenge, codeChallengeMethod, redirectURI) // :690
Between reading the claims and the `handlePKCE` call the handler only validates parameters and returns HTTP errors. There is no consent or approval screen. An unauthenticated victim is sent to login first and returns to the same authorize URL afterward, so the flow also completes after a fresh login.
`handlePKCE` (`admin/server/auth/pkce.go`, approx lines 23-80) re-checks the redirect against the attacker-registered client, mints a one-minute code bound to the victim user id and the attacker client id, and redirects.
// admin/server/auth/pkce.go (handlePKCE, approx lines 23-80)
if !isRedirectURIAllowed(authClient, normalizedRedirect) {
expiration := time.Now().Add(1 * time.Minute)
_, err = a.admin.DB.InsertAuthorizationCode(r.Context(), code, userID, clientID, ...)
http.Redirect(w, r, redirectWithCode, http.StatusFound) // 302 to the attacker redirect with code and state
The redirect was registered by the attacker in link 2, so it passes. The code is for the victim and is delivered to the attacker host.
### 5. The token endpoint exchanges the code with no client secret
`getAccessTokenForAuthorizationCode` (`admin/server/auth/pkce.go`, approx lines 83 onward) is a public-client PKCE exchange.
// admin/server/auth/pkce.go (getAccessTokenForAuthorizationCode)
codeVerifier := values.Get("code_verifier")
if !verifyCodeChallenge(codeVerifier, authCode.CodeChallenge, authCode.CodeChallengeMethod) {
// no client_secret is read in this handler, verifyCodeChallenge accepts only S256
The attacker created the code challenge in the phishing link and holds the matching verifier, so the exchange succeeds without any secret.
### 6. The long-lived branch issues a token with a nil TTL
The scope constant is defined in the auth package.
longLivedAccessTokenScope = "long_lived_access_token"
The exchange reads the scope from the client record and takes the long-lived branch.
longLivedAccess := hasScope(scope, longLivedAccessTokenScope) // :174
// Issue long-lived access token for clients explicitly whitelisted :177
authToken, err := a.admin.IssueUserAuthToken(r.Context(), userID, authCode.ClientID, "", nil, nil, false) // :179 nil TTL
// AccessToken: authToken.Token().String(),
`hasScope` is an exact whitespace-split match, so the attacker-registered scope string triggers the branch.
func hasScope(scopes, scope string) bool {
for _, token := range strings.Fields(scopes) {
The at line 177 says the branch is for whitelisted clients, but the gate is `hasScope` over the client's own registered scope, and registration is unauthenticated, so a self-registered client selects this branch for itself.
### 7. The issuer stores no expiry and the middleware accepts the token
`IssueUserAuthToken` (`admin/auth_token.go:51-68`) sets an expiry only when the TTL is non-nil.
func (s *Service) IssueUserAuthToken(ctx context.Context, userID, clientID, displayName string,
representingUserID *string, ttl *time.Duration, refresh bool) (AuthToken, error) {
tkn := authtoken.NewRandom(authtoken.TypeUser)
// InsertUserAuthToken(..., ExpiresOn: expiresOn, ...) :68
With the nil TTL from link 6, `expiresOn` stays nil and is stored as a nil expiry. There is no default and no maximum cap. The validation path rejects a user token only when `uat.ExpiresOn != nil && uat.ExpiresOn.Before(time.Now())`, so a nil expiry is never treated as expired.
The admin auth middleware (`admin/server/auth/middleware.go`, approx lines 192-211) validates the bearer token and attaches the resulting claims.
// admin/server/auth/middleware.go (approx lines 192-211)
validated, err := a.admin.ValidateAuthToken(ctx, token)
claims := newAuthTokenClaims(validated, a.admin)
The request now runs as the victim. Per-RPC authorization uses the victim's own roles across the organizations and projects the victim can access.
participant AT as Attacker (unauthenticated)
participant VB as Victim browser (logged in)
AT->>RG: POST register with external redirect and long lived access token scope
RG-->>AT: returns client id, scope stored verbatim
AT->>VB: send authorize link with attacker client id and S256 challenge
VB->>AZ: GET authorize inside the victim session
Note over AZ: reads victim user id from claims, no consent screen
AZ-->>VB: 302 to attacker redirect carrying the code
VB->>AT: browser delivers the code to the attacker host
AT->>TK: exchange code with verifier, no client secret
TK-->>AT: access token with expires in zero, never expires
AT->>MW: call AdminService RPC with the never expiring token
MW-->>AT: request runs as the victim across their orgs
> Preconditions: the Rill admin server OAuth endpoints are reachable by the attacker for registration and by the victim for authorize, and the victim has an authenticated session with the admin server (or logs in when redirected). The request examples below are illustrative and were NOT executed against any deployment under this static-only review.
1. **Register a client (unauthenticated).** The attacker sends a registration request with an external redirect host and the long-lived scope. Illustrative, unexecuted:
"redirect_uris": ["
The handler stores the scope and redirect verbatim and returns a `client_id`. State change: a new auth client exists whose scope is `long_lived_access_token` and whose redirect is an attacker host.
2. **Build the authorize link.** The attacker generates a PKCE pair locally, keeps the `code_verifier`, and puts the S256 `code_challenge` and the registered `client_id` and `redirect_uri` into an authorize URL. Illustrative, unexecuted:
https:// /auth/oauth/authorize?response_type=code&client_id=
&redirect_uri=
&code_challenge= &code_challenge_method=S256&state=
3. **Phish the victim.** The victim opens the link while logged in. The authorize handler reads the victim user id from the session, finds no consent step, validates the redirect against the attacker-registered client, mints a one-minute code bound to the victim, and 302-redirects to ` with the code. If the victim is not logged in, the handler redirects to login with the authorize URL preserved, and the flow resumes after the victim authenticates. State change: the attacker host receives an authorization code for the victim.
4. **Exchange the code.** The attacker calls the token endpoint with the code and the verifier and no client secret. Illustrative, unexecuted:
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code= &client_id=
&redirect_uri=
Because the client scope contains `long_lived_access_token`, the server calls `IssueUserAuthToken` with a nil TTL and returns a response whose `expires_in` is `0`. State change: a never-expiring user auth token now exists for the victim.
5. **Use the token as the victim.** The attacker presents the token as a bearer credential to any AdminService RPC. The middleware validates it and runs the request as the victim across the victim's organizations and projects, which includes exchange for per-project runtime credentials. The token does not expire, so the access persists until an administrator explicitly revokes it.
This finding was verified by source review only at the pinned commit. Runtime validation was not performed. No repository was cloned, no code was executed, no PoC was created, and no Rill deployment was contacted.
Confirmed at these exact source locations at commit `b9ea8c6f215e56c4c0848771e70b3ec38346eacc`:
- `admin/server/auth/handlers.go:126`, the register route mounted with `checkLimit` only and no `a.HTTPMiddleware`, with the sibling authorize route in the same block wrapped with `a.HTTPMiddleware`.
- `admin/server/auth/mcp_oauth.go` approx 174-205, `sanitizeRedirectURIs` enforcing only scheme, non-empty host, and no fragment.
- `admin/server/auth/mcp_oauth.go` approx 77-153, `handleOAuthRegister` storing `sanitizeScope(req.Scope)`, `sanitizeGrantTypes(req.GrantTypes)`, and the redirect list verbatim with no scope allowlist.
- `admin/server/auth/handlers.go:650`, `:651`, `:666`, `:690`, `handleAuthorizeRequest` reading the victim id from claims and calling `handlePKCE` with no consent step.
- `admin/server/auth/pkce.go` approx 23-80, `handlePKCE` validating the attacker redirect and issuing a code bound to the victim.
- `admin/server/auth/pkce.go` approx 83 onward, the public-client exchange with no client secret and `verifyCodeChallenge` accepting only S256.
- `admin/server/auth/auth.go:17`, `longLivedAccessTokenScope = "long_lived_access_token"`.
- `admin/server/auth/pkce.go:174`, `:177`, `:178`, `:179`, `:193`, the long-lived branch, the misleading whitelist , the nil-TTL `IssueUserAuthToken` call, and `ExpiresIn: 0`.
- `admin/auth_token.go:51`, `:55`, `:68`, the nil-TTL path storing a nil `ExpiresOn` with no cap.
- `admin/server/auth/middleware.go` approx 192-211, the user token validated and attached to the request context.
- The register route mounting was re-checked on current `main` and is unchanged.
What was not demonstrated at runtime: the browser 302 to the external redirect, the token endpoint returning `expires_in: 0` for this flow, and the token's acceptance by a live AdminService RPC. These legs are inferred from the source path above and were not executed.
An unauthenticated attacker who gets one logged-in victim to open a crafted authorize link obtains a never-expiring user auth token for that victim. The token authenticates AdminService RPCs as the victim across the victim's organizations and projects and can be exchanged for per-project runtime credentials. The access persists until an administrator revokes the token.
**Managed or internet-facing admin server.** Both the registration endpoint and the authorize endpoint are reachable from the internet, so the attacker can register a client and phish any logged-in user. This is the full account-takeover case.
**Self-hosted behind a network boundary.** The attacker needs network reach to the registration endpoint to register the malicious client. The authorize step still requires a logged-in victim to open the link. Where the registration endpoint is not reachable by the attacker, the chain does not start.
What this does NOT do. It does not bypass database authentication and it grants no privilege beyond the victim's own account. The impact is bounded by the victim's roles. It is not remote code execution. The never-expiring property is established from the nil-TTL storage path and the validation path, not from a live token. The long-lived branch issues a never-expiring access token with no refresh token, and the 365-day refresh token belongs to the separate non-long-lived branch, so the two are not combined.
**Where exactly:** `admin/server/auth/handlers.go:126` mounts registration with no authentication, `admin/server/auth/pkce.go:174-179` grants the `long_lived_access_token` scope from the client's self-declared scope with no server-side allowlist, and `admin/server/auth/handlers.go:650-690` issues an authorization code with no consent step.
- longLivedAccess := hasScope(scope, longLivedAccessTokenScope)
+ // The long-lived scope must come from a server-side allowlist of trusted
+ // first-party clients, not from a self-registered client's own scope field.
+ longLivedAccess := hasScope(scope, longLivedAccessTokenScope) &&
+ a.admin.IsTrustedFirstPartyClient(authCode.ClientID)
Short-term. Require authentication, or an operator-configured allowlist, for `POST /auth/oauth/register`, so an anonymous caller cannot create a client at all. At minimum reject any registration that requests the `long_lived_access_token` scope.
Medium-term. Gate the `long_lived_access_token` scope on a server-side allowlist of trusted first-party client ids rather than the client's self-declared scope, which makes the existing "explicitly whitelisted" true. Add a consent step in `handleAuthorizeRequest` before `handlePKCE` issues a code for any client that is not a trusted first-party client, so the victim sees and approves the grant.
Defense in depth. Restrict `redirect_uris` to a registered first-party host set or loopback for non-first-party clients, so an external redirect cannot receive the code. Add a regression test asserting that a self-registered client cannot obtain a token with `ExpiresIn: 0`.
**Recommended:** authenticate or allowlist dynamic client registration, and gate the long-lived scope and consent-less code issuance on a server-side trusted-client allowlist. These close the chain at its start and at the token sink. Redirect restrictions and the regression test are secondary hardening.
- No public advisory existed at the time of writing. The GitHub advisories page for rilldata/rill shows no published security advisories, and the repository has no `SECURITY.md` at the reviewed commit.
- Pinned source at commit `b9ea8c6f215e56c4c0848771e70b3ec38346eacc`:
- [`handlers.go` register route](
- [`handlers.go` handleAuthorizeRequest](
- [`mcp_oauth.go` registration and sanitizeRedirectURIs](
- [`pkce.go` handlePKCE and long-lived branch](
- [`auth.go` longLivedAccessTokenScope](
- [`auth_token.go` IssueUserAuthToken](
- [`middleware.go` token acceptance](
- Weaknesses: [CWE-306 Missing Authentication for Critical Function]( [CWE-862 Missing Authorization]( [CWE-601 URL Redirection to Untrusted Site](
- OAuth references: [RFC 7591 Dynamic Client Registration]( [RFC 7636 PKCE](
Reported by HaiND (Nguyen Dinh Hai), Post and Telecommunication Institute of Technology, [github.com/Haind03](
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
