Skip to content

Commit 700bf72

Browse files
committed
Add table names for enforcedWhereClause columns
1 parent b53d9a3 commit 700bf72

File tree

2 files changed

+133
-7
lines changed

2 files changed

+133
-7
lines changed

internal-packages/tsql/src/query/printer.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,12 +1664,21 @@ export class ClickHousePrinter {
16641664
*
16651665
* @param column - The column name
16661666
* @param condition - The condition to apply
1667+
* @param tableAlias - Optional table alias to qualify the column reference.
1668+
* When provided, constructs the field chain as [tableAlias, column]
1669+
* so resolveFieldChain will resolve to the correct table in multi-join queries.
16671670
* @returns The AST expression for the condition
16681671
*/
1669-
private createConditionExpression(column: string, condition: WhereClauseCondition): Expression {
1672+
private createConditionExpression(
1673+
column: string,
1674+
condition: WhereClauseCondition,
1675+
tableAlias?: string
1676+
): Expression {
1677+
// When tableAlias is provided, qualify the field chain to ensure it binds
1678+
// to the correct table in multi-join queries
16701679
const fieldExpr: Field = {
16711680
expression_type: "field",
1672-
chain: [column],
1681+
chain: tableAlias ? [tableAlias, column] : [column],
16731682
};
16741683

16751684
if (condition.op === "between") {
@@ -1705,10 +1714,11 @@ export class ClickHousePrinter {
17051714
*
17061715
* This ensures the same enforcedWhereClause can be used across different tables.
17071716
*
1708-
* Note: We use just the column name without table prefix since ClickHouse
1709-
* requires the actual table name (task_runs_v2), not the TSQL alias (task_runs)
1717+
* All guard expressions are qualified with the table alias to ensure they bind
1718+
* to the correct table in multi-join queries, preventing potential security
1719+
* issues where an unqualified column reference could bind to the wrong table.
17101720
*/
1711-
private createEnforcedGuard(tableSchema: TableSchema, _tableAlias: string): Expression | null {
1721+
private createEnforcedGuard(tableSchema: TableSchema, tableAlias: string): Expression | null {
17121722
const { requiredFilters, tenantColumns } = tableSchema;
17131723
const guards: Expression[] = [];
17141724

@@ -1721,24 +1731,26 @@ export class ClickHousePrinter {
17211731
}
17221732

17231733
// Apply all enforced conditions for columns that exist in this table
1734+
// Pass tableAlias to ensure guards are qualified and bind to the correct table
17241735
for (const [column, condition] of Object.entries(this.context.enforcedWhereClause)) {
17251736
// Skip undefined/null conditions (allows conditional inclusion like project_id?: condition)
17261737
if (condition === undefined || condition === null) {
17271738
continue;
17281739
}
17291740
// Only apply if column exists in this table's schema or is a tenant column
17301741
if (validColumns.has(column)) {
1731-
guards.push(this.createConditionExpression(column, condition));
1742+
guards.push(this.createConditionExpression(column, condition, tableAlias));
17321743
}
17331744
}
17341745

17351746
// Add required filters from the table schema (e.g., engine = 'V2')
1747+
// Also qualified with table alias to ensure correct binding in multi-join queries
17361748
if (requiredFilters && requiredFilters.length > 0) {
17371749
for (const filter of requiredFilters) {
17381750
const filterGuard: CompareOperation = {
17391751
expression_type: "compare_operation",
17401752
op: CompareOperationOp.Eq,
1741-
left: { expression_type: "field", chain: [filter.column] } as Field,
1753+
left: { expression_type: "field", chain: [tableAlias, filter.column] } as Field,
17421754
right: { expression_type: "constant", value: filter.value } as Constant,
17431755
};
17441756
guards.push(filterGuard);
@@ -2692,6 +2704,31 @@ export class ClickHousePrinter {
26922704
return columnSchema.clickhouseName || columnSchema.name;
26932705
}
26942706

2707+
// Check if this is a tenant column that's not exposed in the schema's columns
2708+
// These are internal columns used for tenant isolation guards
2709+
const { tenantColumns, requiredFilters } = tableSchema;
2710+
if (tenantColumns) {
2711+
if (
2712+
columnName === tenantColumns.organizationId ||
2713+
columnName === tenantColumns.projectId ||
2714+
columnName === tenantColumns.environmentId
2715+
) {
2716+
// Tenant columns are already ClickHouse column names, return as-is
2717+
return columnName;
2718+
}
2719+
}
2720+
2721+
// Check if this is a required filter column (e.g., engine = 'V2')
2722+
// These are internal columns used for enforced filters
2723+
if (requiredFilters) {
2724+
for (const filter of requiredFilters) {
2725+
if (columnName === filter.column) {
2726+
// Required filter columns are already ClickHouse column names, return as-is
2727+
return columnName;
2728+
}
2729+
}
2730+
}
2731+
26952732
// Column not in schema - this is a security issue, block access
26962733
// Check if the user typed a ClickHouse column name instead of the TSQL name
26972734
for (const [tsqlName, colSchema] of Object.entries(tableSchema.columns)) {

internal-packages/tsql/src/query/security.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,95 @@ describe("Optional Tenant Filters", () => {
590590
});
591591
});
592592

593+
describe("Multi-join Tenant Guard Qualification", () => {
594+
/**
595+
* Security Test: Verifies that tenant guards are properly table-qualified in multi-join queries.
596+
*
597+
* The bug: createEnforcedGuard was building unqualified guard expressions like:
598+
* organization_id = 'org_tenant1'
599+
*
600+
* In a multi-table join where both tables have the same column (organization_id),
601+
* an unqualified reference could potentially bind to the wrong table during resolution,
602+
* or be ambiguous. The guards should be qualified like:
603+
* r.organization_id = 'org_tenant1' AND e.organization_id = 'org_tenant1'
604+
*
605+
* This ensures each table's guard binds to the correct table, not just any matching column.
606+
*/
607+
it("should qualify tenant guards with table alias in JOIN queries", () => {
608+
const { sql } = compile(`
609+
SELECT r.id, e.event_type
610+
FROM task_runs r
611+
JOIN task_events e ON r.id = e.run_id
612+
`);
613+
614+
// The guards should be table-qualified to prevent binding to the wrong table
615+
// Look for pattern like: r.organization_id and e.organization_id (with table alias prefix)
616+
// The exact format in ClickHouse SQL is just "alias.column" after resolution
617+
618+
// Count qualified organization_id references (should have table prefixes)
619+
// In the WHERE clause, we should see both r.organization_id and e.organization_id
620+
const whereClause = sql.substring(sql.indexOf("WHERE"));
621+
622+
// Both tables should have their own qualified tenant guards
623+
// The pattern should be: table_alias.organization_id for each table
624+
expect(whereClause).toMatch(/\br\b[^,]*organization_id/);
625+
expect(whereClause).toMatch(/\be\b[^,]*organization_id/);
626+
});
627+
628+
it("should qualify tenant guards with table alias in LEFT JOIN queries", () => {
629+
const { sql } = compile(`
630+
SELECT r.id, e.event_type
631+
FROM task_runs r
632+
LEFT JOIN task_events e ON r.id = e.run_id
633+
`);
634+
635+
const whereClause = sql.substring(sql.indexOf("WHERE"));
636+
637+
// Both tables should have qualified guards
638+
expect(whereClause).toMatch(/\br\b[^,]*organization_id/);
639+
expect(whereClause).toMatch(/\be\b[^,]*organization_id/);
640+
});
641+
642+
it("should qualify tenant guards in multi-way JOIN queries", () => {
643+
const { sql } = compile(`
644+
SELECT r.id, e1.event_type, e2.event_type
645+
FROM task_runs r
646+
JOIN task_events e1 ON r.id = e1.run_id
647+
JOIN task_events e2 ON r.id = e2.run_id
648+
`);
649+
650+
const whereClause = sql.substring(sql.indexOf("WHERE"));
651+
652+
// All three table aliases should have qualified guards
653+
expect(whereClause).toMatch(/\br\b[^,]*organization_id/);
654+
expect(whereClause).toMatch(/\be1\b[^,]*organization_id/);
655+
expect(whereClause).toMatch(/\be2\b[^,]*organization_id/);
656+
});
657+
658+
it("should ensure guards cannot bind to wrong table by verifying separate qualifications", () => {
659+
const { sql, params } = compile(`
660+
SELECT r.id, e.event_type
661+
FROM task_runs r
662+
JOIN task_events e ON r.id = e.run_id
663+
WHERE r.status = 'completed'
664+
`);
665+
666+
// Count organization_id occurrences with different table prefixes
667+
// This ensures each table gets its own guard, not shared/ambiguous references
668+
const orgIdPattern = /(\w+)\.organization_id/g;
669+
const matches = [...sql.matchAll(orgIdPattern)];
670+
const tableAliases = matches.map(m => m[1]);
671+
672+
// Should have at least 2 different table aliases for organization_id
673+
// (one for task_runs alias 'r' and one for task_events alias 'e')
674+
expect(tableAliases).toContain("r");
675+
expect(tableAliases).toContain("e");
676+
677+
// Both should use the same tenant value (parameterized)
678+
expect(Object.values(params)).toContain("org_tenant1");
679+
});
680+
});
681+
593682
describe("Edge Cases", () => {
594683
it("should handle empty string values", () => {
595684
const { params } = compile("SELECT * FROM task_runs WHERE status = ''");

0 commit comments

Comments
 (0)