n8n 2.39.10 - Declarative Base URL Ownership Check


n8n published [email protected] on 21 September 2026 at 14:07 UTC. It is a stable patch, and the only fix limits declarative routing during base URL ownership checks. RoutingNode.resolveBaseUrl now prepares routes with baseUrlOnly set, so path, operation, send, and output expressions are not evaluated while $parameter and $rawParameter are empty.

The full release notes and downloads are on the GitHub release page. The compare view from [email protected] to this tag is one commit, 164b3f3, recorded as #39146.

runNode in packages/core/src/execution-engine/routing-node.ts builds the real request first. For each property it calls getRequestOptionsFromParameters with parameters, credentials, and $version intact. That pass still fills path, query, body, operations, send, and output.

After that build, the same method decides whether the host belongs to the node. It compares toHostname of the resolved options.baseURL with toHostname of a second resolution from resolveBaseUrl. The second call passes $parameter: {} and $rawParameter: {}, and it keeps $credentials and $version. If the hosts match, baseUrlIsNodeOwned is true. That boolean goes to getCredentialAllowedDomains as credentialOwnedSurface. When the helper returns a list, the list is stored on options.allowedDomains. The comment at the call site says only the host matters, because that is all the domain allowlist checks.

resolveBaseUrl replays requestDefaults.baseURL and the per property routing merge, then returns only scratch.options.baseURL. The method comment names two shapes: a parameter wired into requestDefaults.baseURL, and a property that overrides baseURL in its own routing. Google Cloud Storage upload operations are the cited example. The replay still visits every property.

Before this patch the replay used the full walker. Expressions for routing.operations, every routing.request key, routing.send, and routing.output ran under the empty parameter objects. A path that reads $parameter["endpoint"] has nothing useful to read on that pass. The regression test in the same commit, marked NODE-6014, uses a static baseURL and a dynamic path. The cleared walker still evaluated that path, and preparation failed before the host comparison finished.

getRequestOptionsFromParameters takes baseUrlOnly, and the default is false. runNode omits the argument, so the live request is unchanged. resolveBaseUrl passes true.

Four guards implement the limit. routing.operations is not copied. The routing.request loop continues when the key is anything other than baseURL, which drops url, headers, query, and body expressions on that object. routing.send is skipped, so preSend is not collected and paginate is not evaluated. routing.output is skipped, so maxResults and postReceive stay out of the cleared pass, including an enabled expression on a postReceive action.

Descent still happens. displayParameter still applies, the current property is still read, and selected options, collection entries, and fixedCollection items are still visited with baseUrlOnly forwarded. A fixedCollection value still passes through getParameterValue so the walker can go deeper. A property level baseURL override still lands in the scratch object. Path expressions do not.

A host that exists only on url, a header, a query field, or a preSend hook will not move credentialOwnedSurface on this pass. Caller supplied hosts belong in baseURL, on requestDefaults or on the property routing block.

The walker adds the argument, passes true from resolveBaseUrl, threads it through three recursive calls, and adds the four guards. The file diff is ten lines added and three removed. No environment variable, credential field, or feature flag is added. The backport notes mark documentation as not applicable. The commit is on release-candidate/2.39.x.

The new case sits in packages/core/src/execution-engine/__tests__/routing-node.test.ts. The test name is prepares a dynamic path segment when the base URL is static, and the comment cites NODE-6014. runWithCredential receives { apiKey: 'testApiKey' }. Node parameters set endpoint to project-123. routedUrl is the expression =/tests/{{toPathSegment($parameter["endpoint"])}}. The test reads requestOptions.url and expects /tests/project-123.

That assertion is on the normal path, where parameters exist. It checks the baseUrlOnly default. The cleared pass may ignore toPathSegment. The live pass must still encode project-123 into the path. Turning the flag on inside runNode by mistake fails this test.

Nothing else is in the tag. Workflow definitions and credential records stay as they are. Move to [email protected] when preparation fails on a static baseURL and a path that reads a parameter. Requests that already succeeded on [email protected] keep the same URL, because runNode builds it with the flag off before the ownership replay. The GitHub release page carries the build.