Summary
Three read routes under server/src/modules/tooljet-db/controller.ts take the target organizationId as a raw URL path parameter (/organizations/:organizationId/...): GET .../tables (list tables), GET .../table/:tableName (schema/columns/foreign-keys), and POST .../join (execute a join query and return row data). No guard in any of these three routes' guard chains ever cross-checks that URL param against the caller's own JWT-derived organization, and the shared FeatureAbilityGuard ability check for their underlying features is granted unconditionally, with no role requirement whatsoever. (The controller's fourth read-adjacent route, ALL /proxy/*, uses a different mechanism -- it derives its target organization from the tj-workspace-id header rather than a URL param, and that header is validated against genuine JWT-encoded organization membership elsewhere in the auth pipeline, so it is intentionally excluded from this report's claims.)
Concretely: any authenticated ToolJet user of any role, including the lowest-privilege end-user tier, from a completely unrelated, free self-registered organization, can list every table name, every column definition, and every foreign-key relationship belonging to any other organization's ToolJet Database on the instance, and -- via join_tables -- read the actual stored row data out of those tables. Live-reproduced end-to-end.
This report is one of a pair -- a companion report (submitted alongside this one) covers the write/destroy routes in the same controller, gated by a different (also broken) permission check.
Details
server/src/modules/tooljet-db/controller.ts:68-92:
@InitFeature(FEATURE_KEY.VIEW_TABLES)
@Get('/organizations/:organizationId/tables')
@UseGuards(JwtAuthGuard, FeatureAbilityGuard)
async tables(@Param('organizationId') organizationId) {
const result = await this.tableOperationsService.perform(organizationId, 'view_tables');
return decamelizeKeys({ result });
}
@InitFeature(FEATURE_KEY.VIEW_TABLE)
@Get('/organizations/:organizationId/table/:tableName')
@UseGuards(JwtAuthGuard, FeatureAbilityGuard)
async table(@Body() body, @Param('organizationId') organizationId, @Param('tableName') tableName) {
const result = await this.tableOperationsService.perform(organizationId, 'view_table', { table_name: tableName });
...
}
JwtAuthGuard only verifies the caller holds a valid session for some organization -- it never resolves or validates the :organizationId route param against it. No app/org-resolving guard (comparable to ValidAppGuard used elsewhere in this codebase, or ValidateQueryAppGuard/ValidateDataSourceGuard, both of which correctly scope their respective resources) exists anywhere in this controller's guard chains.
server/src/modules/tooljet-db/ability/guard.ts + the shared base server/src/modules/app/guards/ability.guard.ts:144:
const resourceId = request.tj_resource_id;
const forbiddenFeature = features.find(
(feature: string) => !ability.can(feature, this.getSubjectType(), resourceId || undefined)
);
request.tj_resource_id is set by app/org-resolving guards elsewhere in the codebase (e.g. ValidAppGuard, ValidateQueryAppGuard), but no guard in the tooljet-db controller's chain ever sets it -- so the ability check for every tooljet-db route runs with resourceId = undefined, entirely unbound to any specific organization.
server/src/modules/tooljet-db/ability/index.ts:78:
can([FEATURE_KEY.VIEW_TABLE, FEATURE_KEY.VIEW_TABLES, FEATURE_KEY.JOIN_TABLES], InternalTable);
This line sits outside every conditional in the file -- contrast with every other grant in the same defineAbilityFor method, all wrapped in if (superAdmin || isAdmin || userPermission.tjdbCRUD) or similar. VIEW_TABLE/VIEW_TABLES/JOIN_TABLES are granted to literally every authenticated user regardless of role or permission group.
server/src/modules/tooljet-db/services/tooljet-db-table-operations.service.ts:272-278 (viewTables):
protected async viewTables(organizationId: string) {
return await this.manager.find(InternalTable, {
where: { organizationId }, // attacker-supplied, never validated
select: ['id', 'tableName'],
});
}
Same file, viewTable (~line 120-144) additionally resolves findTenantSchema(organizationId) and returns real column/foreign-key metadata; joinTable (~line 899-958) resolves the victim's actual OrganizationTjdbConfigurations (real per-tenant DB role/credentials), opens a connection to the victim's tenant schema, and returns queryBuilder.getRawMany() -- actual row data, not metadata.
PoC
Environment: fresh ToolJet/ToolJet clone (main, as of 2026-07-19), built and run natively against local PostgreSQL 18 + Redis, TOOLJET_EDITION unset (CE). Reported version: 3.21.52-beta-ce.
- Attacker bootstraps Org A as owner/admin, then invites and completes a second,
builder-role (non-admin) teammate -- this account performs every exploit call.
- Org B (victim) is created with a genuinely separate, invited admin account (
Victim RealUser), via the real invite-token completion flow; the attacker's bootstrap membership in Org B is deleted directly in Postgres (count = 0 verified -- the attacker has zero relationship to Org B for the entire exploit).
- Victim creates a real table in their own ToolJet Database with a deliberately sensitive-looking column:
POST /api/tooljet-db/organizations/{org_b_id}/table
tj-workspace-id: {org_b_id}
{"table_name": "victim_secrets_3e2a95",
"columns": [{"column_name": "id", ...}, {"column_name": "api_key", "data_type": "character varying", ...}, {"column_name": "note", ...}]}
-> 201
- Exploit, authenticated as the attacker builder, using the attacker's own, unrelated
tj-workspace-id header (org A) the entire time:
GET /api/tooljet-db/organizations/{org_b_id}/tables
tj-workspace-id: {org_a_id}
-> 200
{"result":[{"id":"7c83d2ea-...","table_name":"victim_secrets_3e2a95"}]}
GET /api/tooljet-db/organizations/{org_b_id}/table/victim_secrets_3e2a95
tj-workspace-id: {org_a_id}
-> 200
{"result":{"foreign_keys":[],"columns":[
{"column_name":"id", ...},
{"column_name":"api_key","data_type":"character varying", ...},
{"column_name":"note","data_type":"character varying", ...}
]}}
The attacker's session never authenticated into Org B, never held any organization_users row there, and never supplied any Org-B-specific credential -- only the victim's org UUID, guessed/known, in the URL.
- Verification: the disclosed table name and column names (
api_key, note) exactly match what the victim created in step 3, confirming genuine cross-tenant read, not a coincidental match.
Impact
Any authenticated ToolJet user, of any role, against any other organization whose UUID they know:
- lists every table name in that organization's ToolJet Database;
- reads every table's full column definitions (names, data types, constraints, defaults) and foreign-key relationships;
- reads the actual stored row data out of any table via
join_tables.
This affects any application data a victim organization has chosen to store in ToolJet Database -- ToolJet's own built-in, no-external-datasource storage option, positioned in-product as equivalent in trust to a real database connection.
Suggested severity: High, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N (7.7). PR:L: source-confirmed the ability grant requires no role at all (any authenticated user, any role, suffices) -- PR:L rather than PR:N because the attacker still needs an authenticated session of their own, even at the lowest privilege tier. S:C: attacker's authorization is entirely local to their own, unrelated organization; impact lands on a different organization's data. C:H: full disclosure of another organization's internal database structure and actual row contents via join_tables (queryBuilder.getRawMany(), not metadata-only).
Suggested fix: add an organization-resolving guard to every route in tooljet-db/controller.ts (mirroring ValidateQueryAppGuard/ValidateDataSourceGuard elsewhere in this codebase) that verifies the URL's :organizationId matches user.organizationId before the handler runs, and sets request.tj_resource_id so the existing FeatureAbilityGuard ability check is actually bound to that organization. Separately, move the unconditional can([VIEW_TABLE, VIEW_TABLES, JOIN_TABLES], InternalTable) grant in ability/index.ts:78 inside an appropriate permission check.
This report's technical claims were independently blind-reviewed by a separate model session given only the source excerpts and this live transcript (not my own reasoning), which returned: "Verdict: PARTIALLY CONFIRMED as written" -- confirming the core vulnerability fully for the three URL-param routes, while correctly flagging that /proxy/* uses a different (header-based, separately-validated) mechanism and should not be described the same way; that correction is reflected in this final version. The reviewer independently confirmed PR:L is the correct choice over PR:N ("CVSS PR:N means no authentication/privileges are required at all, which is not true here"), and assessed the vector as scoring "a likely CVSS v3.1 base score of 7.7 High."
Disclosure notes: all dynamic validation was performed against a local, throwaway instance built from this exact clone on an isolated VM with dedicated Postgres/Redis created solely for this test; no ToolJet Cloud or third-party instance was touched. The test environment was torn down after reproduction.
Summary
Three read routes under
server/src/modules/tooljet-db/controller.tstake the targetorganizationIdas a raw URL path parameter (/organizations/:organizationId/...):GET .../tables(list tables),GET .../table/:tableName(schema/columns/foreign-keys), andPOST .../join(execute a join query and return row data). No guard in any of these three routes' guard chains ever cross-checks that URL param against the caller's own JWT-derived organization, and the sharedFeatureAbilityGuardability check for their underlying features is granted unconditionally, with no role requirement whatsoever. (The controller's fourth read-adjacent route,ALL /proxy/*, uses a different mechanism -- it derives its target organization from thetj-workspace-idheader rather than a URL param, and that header is validated against genuine JWT-encoded organization membership elsewhere in the auth pipeline, so it is intentionally excluded from this report's claims.)Concretely: any authenticated ToolJet user of any role, including the lowest-privilege
end-usertier, from a completely unrelated, free self-registered organization, can list every table name, every column definition, and every foreign-key relationship belonging to any other organization's ToolJet Database on the instance, and -- viajoin_tables-- read the actual stored row data out of those tables. Live-reproduced end-to-end.This report is one of a pair -- a companion report (submitted alongside this one) covers the write/destroy routes in the same controller, gated by a different (also broken) permission check.
Details
server/src/modules/tooljet-db/controller.ts:68-92:JwtAuthGuardonly verifies the caller holds a valid session for some organization -- it never resolves or validates the:organizationIdroute param against it. No app/org-resolving guard (comparable toValidAppGuardused elsewhere in this codebase, orValidateQueryAppGuard/ValidateDataSourceGuard, both of which correctly scope their respective resources) exists anywhere in this controller's guard chains.server/src/modules/tooljet-db/ability/guard.ts+ the shared baseserver/src/modules/app/guards/ability.guard.ts:144:request.tj_resource_idis set by app/org-resolving guards elsewhere in the codebase (e.g.ValidAppGuard,ValidateQueryAppGuard), but no guard in the tooljet-db controller's chain ever sets it -- so the ability check for every tooljet-db route runs withresourceId = undefined, entirely unbound to any specific organization.server/src/modules/tooljet-db/ability/index.ts:78:This line sits outside every conditional in the file -- contrast with every other grant in the same
defineAbilityFormethod, all wrapped inif (superAdmin || isAdmin || userPermission.tjdbCRUD)or similar.VIEW_TABLE/VIEW_TABLES/JOIN_TABLESare granted to literally every authenticated user regardless of role or permission group.server/src/modules/tooljet-db/services/tooljet-db-table-operations.service.ts:272-278(viewTables):Same file,
viewTable(~line 120-144) additionally resolvesfindTenantSchema(organizationId)and returns real column/foreign-key metadata;joinTable(~line 899-958) resolves the victim's actualOrganizationTjdbConfigurations(real per-tenant DB role/credentials), opens a connection to the victim's tenant schema, and returnsqueryBuilder.getRawMany()-- actual row data, not metadata.PoC
Environment: fresh
ToolJet/ToolJetclone (main, as of 2026-07-19), built and run natively against local PostgreSQL 18 + Redis,TOOLJET_EDITIONunset (CE). Reported version:3.21.52-beta-ce.builder-role (non-admin) teammate -- this account performs every exploit call.Victim RealUser), via the real invite-token completion flow; the attacker's bootstrap membership in Org B is deleted directly in Postgres (count = 0verified -- the attacker has zero relationship to Org B for the entire exploit).tj-workspace-idheader (org A) the entire time:The attacker's session never authenticated into Org B, never held any
organization_usersrow there, and never supplied any Org-B-specific credential -- only the victim's org UUID, guessed/known, in the URL.api_key,note) exactly match what the victim created in step 3, confirming genuine cross-tenant read, not a coincidental match.Impact
Any authenticated ToolJet user, of any role, against any other organization whose UUID they know:
join_tables.This affects any application data a victim organization has chosen to store in ToolJet Database -- ToolJet's own built-in, no-external-datasource storage option, positioned in-product as equivalent in trust to a real database connection.
Suggested severity: High,
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N(7.7).PR:L: source-confirmed the ability grant requires no role at all (any authenticated user, any role, suffices) --PR:Lrather thanPR:Nbecause the attacker still needs an authenticated session of their own, even at the lowest privilege tier.S:C: attacker's authorization is entirely local to their own, unrelated organization; impact lands on a different organization's data.C:H: full disclosure of another organization's internal database structure and actual row contents viajoin_tables(queryBuilder.getRawMany(), not metadata-only).Suggested fix: add an organization-resolving guard to every route in
tooljet-db/controller.ts(mirroringValidateQueryAppGuard/ValidateDataSourceGuardelsewhere in this codebase) that verifies the URL's:organizationIdmatchesuser.organizationIdbefore the handler runs, and setsrequest.tj_resource_idso the existingFeatureAbilityGuardability check is actually bound to that organization. Separately, move the unconditionalcan([VIEW_TABLE, VIEW_TABLES, JOIN_TABLES], InternalTable)grant inability/index.ts:78inside an appropriate permission check.This report's technical claims were independently blind-reviewed by a separate model session given only the source excerpts and this live transcript (not my own reasoning), which returned: "Verdict: PARTIALLY CONFIRMED as written" -- confirming the core vulnerability fully for the three URL-param routes, while correctly flagging that
/proxy/*uses a different (header-based, separately-validated) mechanism and should not be described the same way; that correction is reflected in this final version. The reviewer independently confirmedPR:Lis the correct choice overPR:N("CVSS PR:N means no authentication/privileges are required at all, which is not true here"), and assessed the vector as scoring "a likely CVSS v3.1 base score of 7.7 High."Disclosure notes: all dynamic validation was performed against a local, throwaway instance built from this exact clone on an isolated VM with dedicated Postgres/Redis created solely for this test; no ToolJet Cloud or third-party instance was touched. The test environment was torn down after reproduction.