From 19d32bc56cf0f094226af64f92e1c2f6ab8f8777 Mon Sep 17 00:00:00 2001 From: Gustavo Gonzaga Date: Mon, 21 Sep 2026 13:34:53 -0300 Subject: [PATCH 1/2] fix: allow instanceName from body on /instance/create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-instance auth bypass fix (7a55a2bf) strips instanceName and instanceId from any untrusted body or query via sanitizeUntrustedInput(). Every other instance route carries ":instanceName" in its path, so the name is read from request.params and stripping the body copy is correct. But /instance/create has no path parameter — the name can only ever come from the body, so the sanitizer removed the one required field of the request. That surfaced first as a Prisma 500 ("Argument `name` is missing") and, after the name was defaulted, as a 400 "The instanceName cannot be empty". Creating an instance was impossible. Parameterize the protected field list so each call site declares what it needs, and pass ['instanceId'] on /instance/create. This does not reopen the bypass: instanceId is server-generated and stays protected, and every other route keeps the full default list. --- src/api/abstract/abstract.router.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/api/abstract/abstract.router.ts b/src/api/abstract/abstract.router.ts index 3ff633d58b..6d43760172 100644 --- a/src/api/abstract/abstract.router.ts +++ b/src/api/abstract/abstract.router.ts @@ -17,11 +17,14 @@ const logger = new Logger('Validate'); const PROTECTED_INSTANCE_FIELDS = ['instanceName', 'instanceId'] as const; -function sanitizeUntrustedInput(source: Record | undefined): Record { +function sanitizeUntrustedInput( + source: Record | undefined, + protectedFields: readonly string[] = PROTECTED_INSTANCE_FIELDS, +): Record { if (!source || typeof source !== 'object') return {}; const sanitized: Record = {}; for (const [key, value] of Object.entries(source)) { - if ((PROTECTED_INSTANCE_FIELDS as readonly string[]).includes(key)) { + if (protectedFields.includes(key)) { logger.warn(`Ignoring attempt to override protected field "${key}" via untrusted input`); continue; } @@ -51,7 +54,9 @@ export abstract class RouterBroker { } if (request.originalUrl.includes('/instance/create')) { - Object.assign(instance, sanitizeUntrustedInput(body)); + // /instance/create has no ":instanceName" in its path, so the name can only come from the + // body and must be allowed through. instanceId stays protected: it is server-generated. + Object.assign(instance, sanitizeUntrustedInput(body, ['instanceId'])); } Object.assign(ref, body); From c91e91d3ce46f7dbdc99e0ac2798bab46afe52ea Mon Sep 17 00:00:00 2001 From: Gustavo Gonzaga Date: Mon, 21 Sep 2026 13:35:27 -0300 Subject: [PATCH 2/2] fix: allow instanceName and instanceId filters on /instance/fetchInstances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /instance/fetchInstances?instanceName=X is a lookup route: like /instance/create it has no ":instanceName" in its path, so its filter can only arrive in the query string. sanitizeUntrustedInput() stripped both instanceName and instanceId from the query, and did so silently from the caller's point of view — the request still returned 200, just with every instance on the server instead of the one that was asked for. A filtered lookup silently turning into a full listing is worse than an error. Allow both fields through for this route only; every other route keeps the default protected list. --- src/api/abstract/abstract.router.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/api/abstract/abstract.router.ts b/src/api/abstract/abstract.router.ts index 6d43760172..97061f86b4 100644 --- a/src/api/abstract/abstract.router.ts +++ b/src/api/abstract/abstract.router.ts @@ -50,7 +50,10 @@ export abstract class RouterBroker { const instance = request.params as unknown as InstanceDto; if (request?.query && Object.keys(request.query).length > 0) { - Object.assign(instance, sanitizeUntrustedInput(request.query as Record)); + // /instance/fetchInstances has no ":instanceName" in its path either: it is a lookup route + // whose filter (instanceName or instanceId) legitimately arrives in the query string. + const queryProtectedFields = request.originalUrl.includes('/instance/fetchInstances') ? [] : undefined; + Object.assign(instance, sanitizeUntrustedInput(request.query as Record, queryProtectedFields)); } if (request.originalUrl.includes('/instance/create')) {