Skip to content

ToolJet Database read routes have no per-organization authorization at all, allowing any authenticated user (any role) to read any other organization's table schemas and row data

Moderate
shubh22 published GHSA-xqqj-pfc2-vf48 Aug 7, 2026

Package

npm tooljet (npm)

Affected versions

< v3.16.208

Patched versions

v3.16.208

Description

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.

  1. Attacker bootstraps Org A as owner/admin, then invites and completes a second, builder-role (non-admin) teammate -- this account performs every exploit call.
  2. 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).
  3. 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
  1. 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.

  1. 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.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Adjacent
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:A/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

CVE ID

CVE-2026-73068

Weaknesses

Missing Authorization

The product does not perform an authorization check when an actor attempts to access a resource or perform an action. Learn more on MITRE.

Credits