NL Portal Backend Libraries: Unauthenticated form resolver forwards the privileged Objecten-API token to a caller-supplied URL (SSRF)
Description
Summary
The public GraphQL resolvers getFormDefinitionByObjectenApiUrl(url) and the deprecated getFormDefinitionById(id) fetch a caller-supplied URL using the privileged Objecten-API token. Because the /graphql endpoint is permitAll() and these resolvers do not declare a CommonGroundAuthentication parameter, an unauthenticated caller can make the backend issue an outbound request carrying Authorization: Token to a caller-influenced URL on the configured Objecten-API host. This is a constrained (same-host) server-side request forgery combined with missing authorization.
Reported responsibly and confirmed in a local lab build against the project's own WebFlux security stack. No production system was accessed.
Affected
nl.nl-portal:form(the public resolver / entry point) together withnl.nl-portal:objectenapi(where the host guard lives).- First shipped in 1.1.0.RELEASE (2023-10-31); the vulnerable code was introduced on 2023-08-12 (commit
b2f87ca) and is present in every release since (1.1.x, 1.2.5, 1.3.0, the 3.0.x line, and 3.1.0 /next-minor, HEAD45abcd2). Fixed in 3.0.4.RELEASE (see Fix below).
Data flow (confirmed in source)
form/.../graphql/FormDefinitionQuery.kt—@QueryMapping getFormDefinitionByObjectenApiUrl(@Argument url), noCommonGroundAuthenticationparameter (same forgetFormDefinitionById).- →
form/.../service/ObjectsApiFormDefinitionService.kt— passes the URL through unvalidated. - →
zgw/objectenapi/.../service/ObjectenApiService.ktgetObjectByUrl(url)— the only guard is host equality (URI.create(url).host == objectsApiClientConfig.url.host); no scheme/port/path check. - →
zgw/objectenapi/.../client/ObjectsApiClient.ktgetObjectByUrl(url)viawebClientWithoutBaseUrl(), which attaches the default headerAuthorization: Tokento the fully caller-supplied URL.
Reachability: /graphql is permitAll() (core/.../security/OauthSecurityAutoConfiguration.kt). Authentication is only enforced on resolvers that declare a CommonGroundAuthentication parameter; these do not, and there is no @PreAuthorize/instrumentation safety net. The project's own GraphQLEndpointAuthorizationIT lists getFormDefinitionByObjectenApiUrl as an intentionally public operation — so the unauthenticated reachability is by design; the defect is that an intentionally-public resolver forwards a privileged token to a caller-influenced URL.
Secondary (defense-in-depth): zgw/zaken-api/.../service/ZakenApiService.kt getZaakDetails calls objectsApiClient.getObjectByUrl directly, bypassing the service-level host guard. It is currently only reachable via the authenticated ZaakQuery.zaakdetails field resolver with server-derived URLs, so it is not an unauthenticated vector today — but it shows why the guard belongs in the client.
Proof of concept (lab, against the real WebFlux stack)
- An unauthenticated
POST /graphqlcallinggetFormDefinitionByObjectenApiUrl(url: ...)executes without authentication. - With the configured Objecten-API host pointed at a mock server, an outbound request to a caller-chosen port/path on that host carried
Authorization: Token— confirming the token is attached to caller-influenced URLs.
Impact and severity — important limitations
Assessed as Medium because two code-level facts constrain practical impact:
- No cross-host SSRF / token exfiltration in standard deployments. The token only travels to the *configured* Objecten-API host. Exfiltration requires an attacker-controlled listener at that host (a different port/path routing elsewhere) — generally not the case in managed deployments. A range of URL-parser bypass payloads was tested (userinfo
@,%2f/%00/%09, backslash,#/?, double-host, trailing-dot, IDN/Unicode full-stop, fraction-slash, IPv6); no parser differential was found between thejava.net.URI-based guard and the Spring/Netty URI builder used by WebClient — every payload either kept the request on the configured host or was rejected (fail-closed). The lab token-leak PoC works only because the configured host there islocalhost; this does not generalize to production.
- Arbitrary PII object read is blocked by typed deserialization. The response is deserialized into
ObjectsApiObject, whose envelope fields anddata.formDefinitionare all non-nullable Kotlin properties (JacksonKotlinModuleregistered). An object without a top-leveldata.formDefinition(e.g. taken/berichten/zaakdetails) fails to deserialize (DecodingException) and returns no data. The resolver can therefore only return objects shaped like a form definition — and form definitions are intentionally public (loaded pre-login).
Escalation conditions that would raise severity toward High: - the Objecten-API host shares infrastructure with an attacker-controllable endpoint (other port/path), enabling capture of the privileged token; or - a URL-parser differential is later found that escapes the host guard.
Remediation
- Move the host validation out of
ObjectenApiService.getObjectByUrland intoObjectsApiClient.getObjectByUrlso the direct callerZakenApiService.getZaakDetailsis covered too, and tighten it from host-only to scheme + host + port + path-prefix. Preferably, do not accept a full URL at all: validate/extract the object UUID and rebuild the URL from the fixed configured base (reuse the existingObjectsApiClient.getObjectByIdpattern,/api/v2/objects/{uuid}). - Separately decide whether
getFormDefinitionByObjectenApiUrl/getFormDefinitionByIdshould remain unauthenticated. They are currently intentionally public (forms load before login); for a stricter posture, add aCommonGroundAuthenticationparameter as in the other resolvers — noting this breaks pre-login form loading.
Credit
Reported responsibly by Ray Sabee (https://whitehatsecurity.nl), independent security researcher — GitHub @raysabee.
Fix
Fixed in 3.0.4.RELEASE (commit 39ad80f, PR #700, "rework form module"): - The unauthenticated resolvers getFormDefinitionByObjectenApiUrl and the deprecated getFormDefinitionById were removed from both FormDefinitionQuery and the GraphQL schema. - getFormDefinitionByName now requires a CommonGroundAuthentication parameter (no longer public). - The URL-based service method findObjectsApiFormDefinitionByUrl(url) was removed and replaced by getObjectsApiFormDefinitionById(objectId: UUID), which fetches by UUID via the fixed /api/v2/objects/{uuid} path (no caller-supplied URL, so no SSRF) and validates the object type against the configured form-definition object type. - Form definitions are now retrieved through the new authenticated query getFormDefinitionByTaskId(taskId) in nl.nl-portal:taak, which authorizes the caller against the task (CommonGroundAuthentication, BSN/KVK match, else 401) and derives the form-definition UUID from the task's own server-side data, not from caller input. - No resolver feeds caller-controlled input into ObjectenApiService.getObjectByUrl anymore. The objectenapi module itself was not changed; the fix lives entirely in nl.nl-portal:form and the new nl.nl-portal:taak query.
Upgrade instructions
- Backend: upgrade
nl.nl-portal:*to 3.0.4 (or later). - Frontend: upgrade
nl-portal-frontend-librariesto v3.0.3 (or later). This is required: the removed GraphQL queries (getFormDefinitionByObjectenApiUrl,getFormDefinitionById) and the now-authenticatedgetFormDefinitionByNameare a breaking change. Frontend v3.0.3 uses the new authenticatedgetFormDefinitionByTaskId/getFormDefinitionByNamequeries.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Affected products
2- Range: >=1.1.0, <3.0.4
Patches
Vulnerability mechanics
Root cause
"Missing authorization on public GraphQL resolvers allows an unauthenticated caller to supply a URL that the backend fetches with the privileged Objecten-API token, enabling same-host SSRF."
Attack vector
An unauthenticated attacker sends a `POST /graphql` request calling `getFormDefinitionByObjectenApiUrl(url: ...)` or the deprecated `getFormDefinitionById(id)`. Because the `/graphql` endpoint is `permitAll()` and these resolvers lack a `CommonGroundAuthentication` parameter, the backend forwards the caller-supplied URL to `ObjectsApiClient.getObjectByUrl`, which attaches the `Authorization: Token <objecten-api-token>` header and issues the outbound request [ref_id=1]. The only guard is a host-equality check (`URI.create(url).host == objectsApiClientConfig.url.host`) with no scheme/port/path validation, so the attacker can influence the port and path on the configured Objecten-API host [ref_id=1].
Affected code
The vulnerability lives in `nl.nl-portal:form` (the public GraphQL resolvers `getFormDefinitionByObjectenApiUrl` and `getFormDefinitionById` in `FormDefinitionQuery.kt`) and `nl.nl-portal:objectenapi` (the host guard in `ObjectenApiService.getObjectByUrl` and the client `ObjectsApiClient.getObjectByUrl`). The `/graphql` endpoint is `permitAll()` and these resolvers do not declare a `CommonGroundAuthentication` parameter, so an unauthenticated caller can trigger an outbound request carrying the privileged Objecten-API token to a caller-influenced URL on the configured Objecten-API host [ref_id=1].
What the fix does
The fix in commit `39ad80f` (PR #700, 3.0.4.RELEASE) removes the unauthenticated resolvers `getFormDefinitionByObjectenApiUrl` and `getFormDefinitionById` from `FormDefinitionQuery` and the GraphQL schema entirely [ref_id=1]. The URL-based service method `findObjectsApiFormDefinitionByUrl(url)` is replaced by `getObjectsApiFormDefinitionById(objectId: UUID)`, which fetches by UUID via the fixed `/api/v2/objects/{uuid}` path — no caller-supplied URL means no SSRF vector [ref_id=1]. `getFormDefinitionByName` now requires a `CommonGroundAuthentication` parameter, and form definitions are retrieved through the new authenticated query `getFormDefinitionByTaskId(taskId)` in `nl.nl-portal:taak`, which derives the form-definition UUID from server-side task data rather than caller input [ref_id=1].
Preconditions
- configThe /graphql endpoint must be permitAll() (default configuration)
- authNo authentication required — the resolver does not declare a CommonGroundAuthentication parameter
- inputAttacker must know the GraphQL query name (publicly known from open-source schema)
- inputThe caller-supplied URL must pass the host-equality check against the configured Objecten-API host
Generated on Jun 19, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.