From 579906b993b4210f585fe90cd1991098e7c8acf6 Mon Sep 17 00:00:00 2001 From: Day Matchullis Date: Thu, 26 Feb 2026 12:43:25 -0700 Subject: [PATCH 01/13] remove component resolver because it doesn't seem to be used anywhere --- apps/studio/src/plugins/ComponentResolverPlugin.ts | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 apps/studio/src/plugins/ComponentResolverPlugin.ts diff --git a/apps/studio/src/plugins/ComponentResolverPlugin.ts b/apps/studio/src/plugins/ComponentResolverPlugin.ts deleted file mode 100644 index 6be9d4be7..000000000 --- a/apps/studio/src/plugins/ComponentResolverPlugin.ts +++ /dev/null @@ -1,11 +0,0 @@ - - - - - -export default { - components: {} - install: function (Vue, opts) { - this.components = opts - } -} From bead81f692c6c5277a7ff1ff4283f7f0f7c78dcd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:32:28 +0000 Subject: [PATCH 02/13] Bump immutable from 4.3.7 to 4.3.8 Bumps [immutable](https://github.com/immutable-js/immutable-js) from 4.3.7 to 4.3.8. - [Release notes](https://github.com/immutable-js/immutable-js/releases) - [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/immutable-js/immutable-js/compare/v4.3.7...v4.3.8) --- updated-dependencies: - dependency-name: immutable dependency-version: 4.3.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index dfd9c8fde..65c35bd46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9858,14 +9858,14 @@ immediate@~3.0.5: integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== immutable@^4.0.0: - version "4.3.7" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381" - integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw== + version "4.3.8" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.8.tgz#02d183c7727fb2bb1d5d0380da0d779dce9296a7" + integrity sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw== immutable@^5.0.2: - version "5.0.3" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.3.tgz#aa037e2313ea7b5d400cd9298fa14e404c933db1" - integrity sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw== + version "5.1.5" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165" + integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A== import-fresh@^3.0.0: version "3.3.1" From 77fecafc37a0f11b8a2e8e106a0de59cec17e62c Mon Sep 17 00:00:00 2001 From: Yoga Setiawan Date: Fri, 6 Mar 2026 15:16:20 +0700 Subject: [PATCH 03/13] feat: support redis:// and rediss:// protocol schemes --- apps/studio/electron-builder-config-test.js | 5 +++++ apps/studio/electron-builder-config.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/apps/studio/electron-builder-config-test.js b/apps/studio/electron-builder-config-test.js index eb5207897..8eb546632 100644 --- a/apps/studio/electron-builder-config-test.js +++ b/apps/studio/electron-builder-config-test.js @@ -103,6 +103,11 @@ module.exports = { name: "SQL Server URL scheme", schemes: ["sqlserver", "microsoftsqlserver", "mssql"], role: "Editor" + }, + { + "name": "Redis URL scheme", + "schemes": ["redis", "rediss"], + "role": "Editor" } ], mac: { diff --git a/apps/studio/electron-builder-config.js b/apps/studio/electron-builder-config.js index a475cf5f3..1eb531e76 100644 --- a/apps/studio/electron-builder-config.js +++ b/apps/studio/electron-builder-config.js @@ -139,6 +139,11 @@ module.exports = { name: "SQL Server URL scheme", schemes: ["sqlserver", "microsoftsqlserver", "mssql"], role: "Editor" + }, + { + "name": "Redis URL scheme", + "schemes": ["redis", "rediss"], + "role": "Editor" } ], mac: { From d182828178128d8f8b1d8a93b88ea22920a86c7a Mon Sep 17 00:00:00 2001 From: Matthew Rathbone Date: Fri, 6 Mar 2026 15:22:30 -0600 Subject: [PATCH 04/13] ci: cache node_modules across test jobs to speed up CI Add a prep job that runs yarn install once and caches the full node_modules tree via actions/cache. All downstream jobs (unit, integration, e2e) restore from cache instead of running their own yarn install, saving ~2-5 min per job across 20+ parallel jobs. Also updates actions/checkout v2 -> v4, fixes deprecated set-output syntax, and standardizes all jobs on ubuntu-24.04. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/studio-test.yml | 108 +++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 33 deletions(-) diff --git a/.github/workflows/studio-test.yml b/.github/workflows/studio-test.yml index 4b3be2e89..e38a9d3ae 100644 --- a/.github/workflows/studio-test.yml +++ b/.github/workflows/studio-test.yml @@ -18,10 +18,10 @@ on: - apps/sqltools/** jobs: - setup: + prep: runs-on: ubuntu-24.04 outputs: - test-chunks: ${{ steps['set-test-chunks'].outputs['test-chunks'] }} + test-chunks: ${{ steps.set-test-chunks.outputs.test-chunks }} steps: - name: 'Setup jq' uses: dcarbone/install-jq-action@v2 @@ -29,27 +29,62 @@ jobs: version: "1.7" force: true - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - id: set-test-chunks name: Set Chunks - run: echo "::set-output name=test-chunks::$(./bin/get-db-files-as-json.sh)" - unit: - name: Unit tests - runs-on: ubuntu-24.04 - steps: - - name: Check out Git repository - uses: actions/checkout@v2 + run: echo "test-chunks=$(./bin/get-db-files-as-json.sh)" >> $GITHUB_OUTPUT - name: Install Node.js, NPM and Yarn uses: actions/setup-node@v3 with: node-version-file: '.nvmrc' - cache: yarn + + - name: Cache node_modules + id: cache-nm + uses: actions/cache@v4 + with: + path: | + node_modules + apps/studio/node_modules + apps/ui-kit/node_modules + apps/sqltools/node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - name: Install dependencies - run: yarn install --frozen-lockfile + if: steps.cache-nm.outputs.cache-hit != 'true' + uses: nick-fields/retry@v2 + with: + timeout_minutes: 20 + max_attempts: 3 + command: yarn install --frozen-lockfile --network-timeout 100000 env: - npm_config_node_gyp: ${{ github.workspace }}${{ runner.os == 'Windows' && '\node_modules\node-gyp\bin\node-gyp.js' || '/node_modules/node-gyp/bin/node-gyp.js' }} + HUSKY: "0" + npm_config_node_gyp: ${{ github.workspace }}/node_modules/node-gyp/bin/node-gyp.js + + unit: + name: Unit tests + runs-on: ubuntu-24.04 + needs: [prep] + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Node.js, NPM and Yarn + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + + - name: Restore node_modules cache + uses: actions/cache/restore@v4 + with: + path: | + node_modules + apps/studio/node_modules + apps/ui-kit/node_modules + apps/sqltools/node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + fail-on-cache-miss: true - name: Lint run: yarn workspace beekeeper-studio run lint @@ -80,20 +115,30 @@ jobs: name: 🥞 ${{ matrix.chunk[0] }} runs-on: ubuntu-24.04 needs: - - setup + - prep strategy: fail-fast: false matrix: - chunk: ${{ fromJson(needs.setup.outputs['test-chunks']) }} + chunk: ${{ fromJson(needs.prep.outputs['test-chunks']) }} steps: - name: Check out Git repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install Node.js, NPM and Yarn uses: actions/setup-node@v3 with: node-version-file: '.nvmrc' - cache: yarn + + - name: Restore node_modules cache + uses: actions/cache/restore@v4 + with: + path: | + node_modules + apps/studio/node_modules + apps/ui-kit/node_modules + apps/sqltools/node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + fail-on-cache-miss: true - name: Install libaio (for oracle) run: sudo apt install libaio-dev @@ -101,15 +146,6 @@ jobs: - name: Symlink libaio v1 (for oracle) run: sudo ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 - - name: yarn install (with retry) - uses: nick-fields/retry@v2 - with: - timeout_minutes: 20 - max_attempts: 3 - command: "yarn install --frozen-lockfile --network-timeout 100000" - env: - npm_config_node_gyp: ${{ github.workspace }}${{ runner.os == 'Windows' && '\node_modules\node-gyp\bin\node-gyp.js' || '/node_modules/node-gyp/bin/node-gyp.js' }} - - name: Test uses: nick-fields/retry@v2 with: @@ -147,21 +183,27 @@ jobs: e2e: name: E2E tests - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + needs: [prep] steps: - name: Check out Git repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install Node.js, NPM and Yarn uses: actions/setup-node@v3 with: node-version-file: '.nvmrc' - cache: yarn - - name: Install dependencies - run: yarn install --frozen-lockfile - env: - npm_config_node_gyp: ${{ github.workspace }}${{ runner.os == 'Windows' && '\node_modules\node-gyp\bin\node-gyp.js' || '/node_modules/node-gyp/bin/node-gyp.js' }} + - name: Restore node_modules cache + uses: actions/cache/restore@v4 + with: + path: | + node_modules + apps/studio/node_modules + apps/ui-kit/node_modules + apps/sqltools/node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + fail-on-cache-miss: true - name: Start postgres container run: docker compose up psql15 -d From ab89dcaa67e4c61bd1399e0aab80426d082349e4 Mon Sep 17 00:00:00 2001 From: Matthew Rathbone Date: Fri, 6 Mar 2026 14:34:16 -0600 Subject: [PATCH 05/13] fix: Trino listTables returns empty and SSL connections not supported (#3947, #3695) - listTables(null) no longer returns []. Uses buildSchemaFilter() to optionally filter by schema, matching the PostgreSQL driver pattern. - SSL/TLS connections now work. Certificate files (CA, cert, key) are read and passed to trino-client. When no certs are provided, rejectUnauthorized defaults to false (trust server cert). - Removed server:ssl from Trino disabledFeatures so the SSL toggle appears in the connection form. - URL parser now auto-enables ssl for https:// URLs. - Added integration tests for listTables and SSL (with HTTPS-enabled Trino testcontainer). - Added unit tests for SSL config propagation. - Updated Trino connection docs (EN + ES) with SSL section. --- .../backend/lib/db/clients/trino.ts | 36 ++++- .../common/appdb/models/saved_connection.ts | 4 + apps/studio/src/lib/db/clients/index.ts | 1 - .../integration/lib/db/clients/trino.spec.ts | 85 +++++++++-- .../lib/db/clients/trino/container.ts | 118 ++++++++++++++- .../tests/unit/lib/db/clients/trino.spec.ts | 141 ++++++++++++++++++ docs/user_guide/connecting/trino.es.md | 14 +- docs/user_guide/connecting/trino.md | 14 +- 8 files changed, 391 insertions(+), 22 deletions(-) create mode 100644 apps/studio/tests/unit/lib/db/clients/trino.spec.ts diff --git a/apps/studio/src-commercial/backend/lib/db/clients/trino.ts b/apps/studio/src-commercial/backend/lib/db/clients/trino.ts index e182c6f70..bef34e2ba 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/trino.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/trino.ts @@ -1,10 +1,12 @@ import rawLog from "@bksLogger" +import { readFileSync } from "fs" import { IDbConnectionDatabase } from "@/lib/db/types" import { Trino as TrinoNodeClient, BasicAuth, QueryResult, - ConnectionOptions as TrinoConnectionOptions + ConnectionOptions as TrinoConnectionOptions, + SecureContextOptions } from 'trino-client' import { BaseQueryResult, @@ -41,6 +43,7 @@ import { createCancelablePromise, joinFilters } from "@/common/utils" +import { buildSchemaFilter } from "@/lib/db/clients/utils" import { AlterTableSpec, TableKey @@ -109,8 +112,30 @@ export class TrinoClient extends BasicDatabaseClient { catalog: this.database.database } - // TODO: Add ssl using SecureContextOptions (https://trinodb.github.io/trino-js-client/types/ConnectionOptions.html) - + if (this.server.config.ssl) { + const sslOptions: SecureContextOptions = {} + + if (this.server.config.sslCaFile) { + sslOptions.ca = readFileSync(this.server.config.sslCaFile) + } + + if (this.server.config.sslCertFile) { + sslOptions.cert = readFileSync(this.server.config.sslCertFile) + } + + if (this.server.config.sslKeyFile) { + sslOptions.key = readFileSync(this.server.config.sslKeyFile) + } + + if (!sslOptions.key && !sslOptions.ca && !sslOptions.cert) { + sslOptions.rejectUnauthorized = false + } else { + sslOptions.rejectUnauthorized = this.server.config.sslRejectUnauthorized + } + + connectionObj.ssl = sslOptions + } + if ((this.server.config.user != null && this.server.config.user !== '') || (this.server.config.password != null && this.server.config.password !== '')) { connectionObj.auth = new BasicAuth(this.server.config.user, this.server.config.password) } @@ -279,8 +304,9 @@ export class TrinoClient extends BasicDatabaseClient { async listTables(filter?: FilterOptions): Promise { log.info('filters in listTables', filter) - if (!filter) return [] - const sql = `select * from ${this.db}.information_schema.tables` + const schemaFilter = buildSchemaFilter(filter, 'table_schema') + const whereClause = schemaFilter ? `WHERE ${schemaFilter}` : '' + const sql = `select * from ${this.db}.information_schema.tables ${whereClause}` const result = await this.driverExecuteSingle(sql) return result.rows.map((row) => ({ diff --git a/apps/studio/src/common/appdb/models/saved_connection.ts b/apps/studio/src/common/appdb/models/saved_connection.ts index 023a2ad83..7cb886e90 100644 --- a/apps/studio/src/common/appdb/models/saved_connection.ts +++ b/apps/studio/src/common/appdb/models/saved_connection.ts @@ -384,6 +384,10 @@ export class SavedConnection extends DbConnectionBase implements IConnection { this.ssl = true } + if (cleanedUrl.startsWith('https://')) { + this.ssl = true + } + if (parsed.params?.TrustServerCertificate && parsed.params.TrustServerCertificate === 'true') { this.trustServerCertificate = true } diff --git a/apps/studio/src/lib/db/clients/index.ts b/apps/studio/src/lib/db/clients/index.ts index 675ea17dd..107431358 100644 --- a/apps/studio/src/lib/db/clients/index.ts +++ b/apps/studio/src/lib/db/clients/index.ts @@ -215,7 +215,6 @@ export const CLIENTS: ClientConfig[] = [ topLevelEntity: 'Catalog', defaultPort: 8080, disabledFeatures: [ - 'server:ssl', 'server:socketPath', 'cancelQuery', // TODO how to do this? ], diff --git a/apps/studio/tests/integration/lib/db/clients/trino.spec.ts b/apps/studio/tests/integration/lib/db/clients/trino.spec.ts index 087ec9b4e..386a81680 100644 --- a/apps/studio/tests/integration/lib/db/clients/trino.spec.ts +++ b/apps/studio/tests/integration/lib/db/clients/trino.spec.ts @@ -1,6 +1,6 @@ import { Network} from "testcontainers" import { DBTestUtil, dbtimeout } from "../../../../lib/db" -import { PostgresTestDriver, TrinoTestDriver } from './trino/container' +import { TrinoBackingPostgresDriver, TrinoHttpDriver, TrinoHttpsDriver } from './trino/container' import { TableOrView } from "@/lib/db/models" // import { runCommonTests, runReadOnlyTests } from "./all" @@ -20,30 +20,30 @@ function testWith(dockerTag: TestVersion, socket = false, readonly = false) { beforeAll(async () => { const network = await new Network().start() - await PostgresTestDriver.start(dockerTag, socket, readonly, network) + await TrinoBackingPostgresDriver.start(dockerTag, socket, readonly, network) - dbfeeder = new DBTestUtil(PostgresTestDriver.config, "banana", PostgresTestDriver.utilOptions) + dbfeeder = new DBTestUtil(TrinoBackingPostgresDriver.config, "banana", TrinoBackingPostgresDriver.utilOptions) await dbfeeder.setupdb() // now set up the trino container - await TrinoTestDriver.start(dockerTag, readonly, network) + await TrinoHttpDriver.start(dockerTag, readonly, network) - util = new DBTestUtil(TrinoTestDriver.config, 'postgresql', { dialect: 'trino' }) + util = new DBTestUtil(TrinoHttpDriver.config, 'postgresql', { dialect: 'trino' }) await util.connection.connect() // Trino uses catalogs instead of databases, access PostgreSQL through 'postgresql' catalog - // util = new DBTestUtil(TrinoTestDriver.config, "postgresql", TrinoTestDriver.utilOptions) + // util = new DBTestUtil(TrinoHttpDriver.config, "postgresql", TrinoHttpDriver.utilOptions) // await util.setupdb() }) afterAll(async () => { - // await util.disconnect() - await TrinoTestDriver.stop() - await dbfeeder.disconnect() if (util.connection) { await util.connection.disconnect() } + await TrinoHttpDriver.stop() + await dbfeeder.disconnect() + await TrinoBackingPostgresDriver.stop() }) describe("Read Operations", () => { @@ -57,9 +57,14 @@ function testWith(dockerTag: TestVersion, socket = false, readonly = false) { expect(tableNames).toContain('jobs') }) - it("List tables should return empty array when filter is undefined", async () => { - const tables: TableOrView[] = await util.connection.listTables() - expect(tables).toEqual([]) + it("List tables should return tables when filter is null (bug #3947)", async () => { + const tables: TableOrView[] = await util.connection.listTables(null) + expect(tables.length).toBeGreaterThanOrEqual(3) + }) + + it("List tables should only return tables matching the schema filter", async () => { + const tables: TableOrView[] = await util.connection.listTables({ schema: 'public' }) + tables.forEach(t => expect(t.schema).toBe('public')) }) it("List table columns should work", async () => { @@ -257,6 +262,62 @@ function testWith(dockerTag: TestVersion, socket = false, readonly = false) { TEST_VERSIONS.forEach(({ version, socket, readonly }) => testWith(version, socket, readonly)) +describe('Trino SSL connection (bug #3695)', () => { + jest.setTimeout(dbtimeout) + + // Separate instance to avoid mutating the shared TrinoBackingPostgresDriver singleton + const sslPgDriver = Object.create(TrinoBackingPostgresDriver, { + container: { value: null, writable: true }, + config: { value: null, writable: true }, + utilOptions: { value: null, writable: true }, + }) + let dbfeeder: DBTestUtil + let util: DBTestUtil + + beforeAll(async () => { + const network = await new Network().start() + + await sslPgDriver.start('latest', false, false, network) + + dbfeeder = new DBTestUtil(sslPgDriver.config, "banana", sslPgDriver.utilOptions) + await dbfeeder.setupdb() + + await TrinoHttpsDriver.start('latest', false, network) + + util = new DBTestUtil(TrinoHttpsDriver.config, 'postgresql', { dialect: 'trino' }) + await util.connection.connect() + }) + + afterAll(async () => { + if (util?.connection) { + await util.connection.disconnect() + } + await TrinoHttpsDriver.stop() + await dbfeeder.disconnect() + await sslPgDriver.container?.stop() + }) + + it("Should connect to Trino over HTTPS with ssl=true and CA cert", async () => { + const version = await util.connection.versionString() + expect(version).toBeDefined() + expect(typeof version).toBe('string') + expect(version.length).toBeGreaterThan(0) + }) + + it("Should be able to list tables over SSL", async () => { + const tables: TableOrView[] = await util.connection.listTables({ schema: 'public' }) + const tableNames = tables.map(t => t.name) + expect(tables.length).toBeGreaterThanOrEqual(3) + expect(tableNames).toContain('people') + }) + + it("Should be able to query data over SSL", async () => { + const result = await util.connection.selectTop('people', 0, 10, [], [], 'public', []) + expect(result.result).toBeDefined() + expect(Array.isArray(result.result)).toBe(true) + }) +}) + // Additional util.connection tests describe('Trino util.Connection Edge Cases', () => { jest.setTimeout(dbtimeout) diff --git a/apps/studio/tests/integration/lib/db/clients/trino/container.ts b/apps/studio/tests/integration/lib/db/clients/trino/container.ts index 845964a14..3c4265956 100644 --- a/apps/studio/tests/integration/lib/db/clients/trino/container.ts +++ b/apps/studio/tests/integration/lib/db/clients/trino/container.ts @@ -4,9 +4,10 @@ import { GenericContainer, Wait, StartedTestContainer } from "testcontainers"; import path from 'path' import os from 'os' import fs from 'fs' +import { execSync } from 'child_process' import { IDbConnectionServerConfig } from "@/lib/db/types"; -export const PostgresTestDriver = { +export const TrinoBackingPostgresDriver = { container: null, utilOptions: null, config: null, @@ -87,7 +88,7 @@ export const PostgresTestDriver = { } } -export const TrinoTestDriver = { +export const TrinoHttpDriver = { container: null as StartedTestContainer | null, pgContainer: null as StartedTestContainer | null, utilOptions: null as Options | null, @@ -146,3 +147,116 @@ connection-password=example`; await this.container?.stop() } } + +function generateTrinoSslFiles(tempDir: string) { + const certDir = path.join(tempDir, 'certs') + fs.mkdirSync(certDir, { recursive: true }) + + const keyFile = path.join(certDir, 'trino.key') + const certFile = path.join(certDir, 'trino.crt') + const p12File = path.join(certDir, 'trino.p12') + const keystorePassword = 'trinopass' + + // Generate self-signed cert and private key + execSync( + `openssl req -x509 -newkey rsa:2048 -keyout ${keyFile} -out ${certFile} ` + + `-days 1 -nodes -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"`, + { stdio: 'pipe' } + ) + + // Convert to PKCS12 keystore for Trino's Java TLS + execSync( + `openssl pkcs12 -export -in ${certFile} -inkey ${keyFile} ` + + `-out ${p12File} -passout pass:${keystorePassword}`, + { stdio: 'pipe' } + ) + + return { keyFile, certFile, p12File, keystorePassword, certDir } +} + +export const TrinoHttpsDriver = { + container: null as StartedTestContainer | null, + config: null as IDbConnectionServerConfig | null, + certFile: null as string | null, + + async start(dockerTag: string, readonly: boolean, network) { + const startupTimeout = dbtimeout * 2 + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trino-ssl-')) + + // Generate SSL certs and keystore + const ssl = generateTrinoSslFiles(tempDir) + this.certFile = ssl.certFile + + // Create catalog config for postgresql connector + const catalogDir = path.join(tempDir, 'catalog') + fs.mkdirSync(catalogDir) + fs.writeFileSync( + path.join(catalogDir, 'postgresql.properties'), + `connector.name=postgresql +connection-url=jdbc:postgresql://postgres:5432/banana +connection-user=postgres +connection-password=example` + ) + + // Generate a shared secret for internal communication + const sharedSecret = require('crypto').randomBytes(64).toString('base64') + + // Shell script to merge SSL settings into the existing config.properties, + // then start Trino. This preserves default properties like catalog.management. + const entrypointScript = path.join(tempDir, 'entrypoint.sh') + fs.writeFileSync(entrypointScript, [ + '#!/bin/bash', + 'set -e', + "sed -i '/^http-server.http.port=/d' /etc/trino/config.properties", + "sed -i '/^discovery.uri=/d' /etc/trino/config.properties", + `cat >> /etc/trino/config.properties << 'SSLEOF'`, + 'http-server.http.enabled=false', + 'http-server.https.enabled=true', + 'http-server.https.port=8443', + `http-server.https.keystore.path=/etc/trino/certs/trino.p12`, + `http-server.https.keystore.key=${ssl.keystorePassword}`, + 'discovery.uri=https://localhost:8443', + 'internal-communication.https.required=true', + `internal-communication.shared-secret=${sharedSecret}`, + 'SSLEOF', + 'exec /usr/lib/trino/bin/run-trino', + ].join('\n')) + fs.chmodSync(entrypointScript, '755') + + this.container = await new GenericContainer(`trinodb/trino:${dockerTag}`) + .withNetwork(network) + .withExposedPorts(8443) + .withBindMounts([ + { source: catalogDir, target: '/etc/trino/catalog', mode: 'ro' }, + { source: ssl.certDir, target: '/etc/trino/certs', mode: 'ro' }, + { source: entrypointScript, target: '/etc/trino/entrypoint.sh', mode: 'ro' }, + ]) + .withCommand(['/etc/trino/entrypoint.sh']) + .withWaitStrategy(Wait.forLogMessage("SERVER STARTED")) + .withStartupTimeout(startupTimeout) + .start() + + this.config = { + client: 'trino', + host: this.container.getHost(), + port: this.container.getMappedPort(8443), + user: 'test', + password: null, + osUser: 'foo', + ssh: null, + sslCaFile: ssl.certFile, + sslCertFile: null, + sslKeyFile: null, + sslRejectUnauthorized: false, + ssl: true, + domain: null, + socketPath: null, + socketPathEnabled: false, + readOnlyMode: readonly, + } + }, + + async stop() { + await this.container?.stop() + }, +} diff --git a/apps/studio/tests/unit/lib/db/clients/trino.spec.ts b/apps/studio/tests/unit/lib/db/clients/trino.spec.ts new file mode 100644 index 000000000..1d3d7ca0f --- /dev/null +++ b/apps/studio/tests/unit/lib/db/clients/trino.spec.ts @@ -0,0 +1,141 @@ +jest.mock('trino-client', () => { + const mockQuery = jest.fn().mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + data: [['1.0.0']], + columns: [{ name: '_col0', type: 'varchar' }] + } + } + }) + + return { + Trino: { + create: jest.fn().mockReturnValue({ query: mockQuery }) + }, + BasicAuth: jest.fn().mockImplementation((user, pass) => ({ type: 'basic', username: user, password: pass })), + } +}) + +import fs from 'fs' +import os from 'os' +import path from 'path' +import { Trino as TrinoNodeClient } from 'trino-client' +import { TrinoClient } from '@commercial/backend/lib/db/clients/trino' +import { IDbConnectionServer } from '@/lib/db/backendTypes' +import { IDbConnectionDatabase } from '@/lib/db/types' + +function makeServer(overrides: Partial = {}): IDbConnectionServer { + return { + db: {}, + config: { + client: 'trino', + host: 'localhost', + port: 8080, + user: 'testuser', + password: null, + readOnlyMode: false, + osUser: 'testuser', + ssh: null, + sslCaFile: null, + sslCertFile: null, + sslKeyFile: null, + sslRejectUnauthorized: false, + ssl: false, + domain: null, + socketPath: null, + socketPathEnabled: false, + ...overrides, + }, + } as IDbConnectionServer +} + +function makeDatabase(): IDbConnectionDatabase { + return { + database: 'postgresql', + connected: false, + connecting: false, + namespace: null, + } +} + +describe('TrinoClient SSL configuration (bug #3695)', () => { + let tmpDir: string + let caFile: string + let certFile: string + let keyFile: string + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trino-test-')) + caFile = path.join(tmpDir, 'ca.pem') + certFile = path.join(tmpDir, 'cert.pem') + keyFile = path.join(tmpDir, 'key.pem') + fs.writeFileSync(caFile, 'fake-ca') + fs.writeFileSync(certFile, 'fake-cert') + fs.writeFileSync(keyFile, 'fake-key') + }) + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true }) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should pass ssl options to Trino.create() when ssl is true', async () => { + const server = makeServer({ ssl: true }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.ssl).toBeDefined() + }) + + it('should use https protocol when ssl is true', async () => { + const server = makeServer({ ssl: true }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.server).toContain('https') + }) + + it('should pass ssl cert/key/ca files when configured', async () => { + const server = makeServer({ + ssl: true, + sslCaFile: caFile, + sslCertFile: certFile, + sslKeyFile: keyFile, + }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.ssl).toBeDefined() + expect(createCall.ssl.ca).toBeDefined() + expect(createCall.ssl.cert).toBeDefined() + expect(createCall.ssl.key).toBeDefined() + }) + + it('should set rejectUnauthorized based on config', async () => { + const server = makeServer({ + ssl: true, + sslRejectUnauthorized: false, + }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.ssl).toBeDefined() + expect(createCall.ssl.rejectUnauthorized).toBe(false) + }) + + it('should not pass ssl options when ssl is false', async () => { + const server = makeServer({ ssl: false }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.ssl).toBeUndefined() + }) +}) diff --git a/docs/user_guide/connecting/trino.es.md b/docs/user_guide/connecting/trino.es.md index 3555b907f..bdd1025f7 100644 --- a/docs/user_guide/connecting/trino.es.md +++ b/docs/user_guide/connecting/trino.es.md @@ -21,11 +21,22 @@ Conectarse a una base de datos Trino desde Beekeeper Studio es sencillo. Selecci Para conectarte a una base de datos Trino, necesitaras la siguiente informacion: - **Host**: La direccion IP o nombre de host de tu servidor Trino. -- **Puerto**: El puerto predeterminado es 8080, pero esto se puede personalizar si tu servidor usa un puerto diferente. +- **Puerto**: El puerto predeterminado es 8080 para HTTP, o 8443 para HTTPS. Esto se puede personalizar si tu servidor usa un puerto diferente. - **Nombre de usuario**: Tu nombre de usuario de Trino, siendo default el valor predeterminado tipico. - **Contrasena**: Tu contrasena de Trino, si aplica. - **Catalogo predeterminado (opcional)**: El catalogo al que quieres conectarte inicialmente al inicio +### Conexiones SSL / HTTPS + +Si tu coordinador Trino esta configurado con TLS/HTTPS, habilita **SSL** en el formulario de conexion. Beekeeper Studio soporta tres modos de SSL: + +1. **Confiar en el certificado del servidor** — Habilita SSL sin proporcionar archivos de certificado. Beekeeper Studio se conectara por HTTPS pero no verificara el certificado del servidor. Esta es la opcion mas sencilla y funciona con certificados autofirmados. +2. **Proporcionar un certificado CA** — Si tu servidor Trino usa un certificado firmado por una CA privada, proporciona el archivo de certificado CA. Deja "Rechazar no autorizados" desmarcado para permitir la conexion. +3. **Verificacion completa de certificados** — Proporciona el certificado CA y opcionalmente un certificado de cliente y archivo de clave, luego marca "Rechazar no autorizados" para aplicar la verificacion TLS completa. + +!!! tip + Si importas una URL de conexion que comienza con `https://`, SSL se habilitara automaticamente. + ### Probar tu conexion de Trino Antes de guardar los detalles de tu conexion, Beekeeper Studio te permite probar la conexion: @@ -40,6 +51,7 @@ Una vez que los detalles de tu conexion han sido verificados, puedes elegir guar ## Funciones soportadas +- Conexiones SSL / HTTPS (con archivos opcionales de CA, certificado de cliente y clave) - Vista de datos de tabla - Ordenamiento y filtrado de datos de tabla - Vista de estructura de tabla diff --git a/docs/user_guide/connecting/trino.md b/docs/user_guide/connecting/trino.md index 00cd413c9..8a6d28cb8 100644 --- a/docs/user_guide/connecting/trino.md +++ b/docs/user_guide/connecting/trino.md @@ -21,11 +21,22 @@ Connecting to a Trino database from Beekeeper Studio is straightforward. SElect To connect to a Trino database, you'll need the following information: - **Host**: The IP address or hostname of your Trino server. -- **Port**: The default port is 8080, but this can be customized if your server uses a different port. +- **Port**: The default port is 8080 for HTTP, or 8443 for HTTPS. This can be customized if your server uses a different port. - **Username**: Your Trino username, with default being the typical default. - **Password**: Your Trino password, if applicable. - **Default Catalog (optional)**: The catalog you want initially connected to at startup +### SSL / HTTPS Connections + +If your Trino coordinator is configured with TLS/HTTPS, enable **SSL** in the connection form. Beekeeper Studio supports three SSL modes: + +1. **Trust the server certificate** — Enable SSL without providing any certificate files. Beekeeper Studio will connect over HTTPS but will not verify the server's certificate. This is the simplest option and works with self-signed certificates. +2. **Provide a CA certificate** — If your Trino server uses a certificate signed by a private CA, provide the CA certificate file. Leave "Reject Unauthorized" unchecked to allow the connection. +3. **Full certificate verification** — Provide the CA certificate and optionally a client certificate and key file, then check "Reject Unauthorized" to enforce full TLS verification. + +!!! tip + If you import a connection URL that starts with `https://`, SSL will be enabled automatically. + ### Testing Your Trino Connection Before saving your connection details, Beekeeper Studio allows you to test the connection: @@ -40,6 +51,7 @@ Once your connection details have been verified, you can choose to save them by ## Supported Features +- SSL / HTTPS connections (with optional CA, client cert, and key files) - Table data view - Table data sorting, filtering - Table structure view From 1c145aee61894924ba545332f13b4d39c19883a7 Mon Sep 17 00:00:00 2001 From: Matthew Rathbone Date: Fri, 6 Mar 2026 14:48:02 -0600 Subject: [PATCH 06/13] fix: escape user-supplied values in SQL queries (buildSchemaFilter + Trino) - buildSchemaFilter now uses escapeString() to escape single quotes in schema, only, and ignore filter values. - Trino driver now wraps this.db with wrapIdentifier() in all queries (listSchemas, listTables, listTableColumns, buildPaginatedQuery). - Trino listTableColumns escapes table and schema string literals. - Added unit tests for SQL injection in buildSchemaFilter and Trino. --- .../backend/lib/db/clients/trino.ts | 14 ++--- apps/studio/src/lib/db/clients/utils.ts | 6 +- .../tests/unit/lib/db/clients/trino.spec.ts | 58 +++++++++++++++++-- .../tests/unit/lib/db/clients/utils.spec.js | 27 ++++++++- 4 files changed, 88 insertions(+), 17 deletions(-) diff --git a/apps/studio/src-commercial/backend/lib/db/clients/trino.ts b/apps/studio/src-commercial/backend/lib/db/clients/trino.ts index bef34e2ba..46e4f111b 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/trino.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/trino.ts @@ -43,7 +43,7 @@ import { createCancelablePromise, joinFilters } from "@/common/utils" -import { buildSchemaFilter } from "@/lib/db/clients/utils" +import { buildSchemaFilter, escapeString } from "@/lib/db/clients/utils" import { AlterTableSpec, TableKey @@ -296,7 +296,7 @@ export class TrinoClient extends BasicDatabaseClient { async listSchemas(filter: SchemaFilterOptions): Promise { log.info('filters in listSchemas', filter) - const sql = `show schemas from ${this.db}` + const sql = `show schemas from ${this.wrapIdentifier(this.db)}` const result = await this.driverExecuteSingle(sql) return result?.rows ? result.rows.map((row) => row.Schema) : [] @@ -306,7 +306,7 @@ export class TrinoClient extends BasicDatabaseClient { log.info('filters in listTables', filter) const schemaFilter = buildSchemaFilter(filter, 'table_schema') const whereClause = schemaFilter ? `WHERE ${schemaFilter}` : '' - const sql = `select * from ${this.db}.information_schema.tables ${whereClause}` + const sql = `select * from ${this.wrapIdentifier(this.db)}.information_schema.tables ${whereClause}` const result = await this.driverExecuteSingle(sql) return result.rows.map((row) => ({ @@ -320,9 +320,9 @@ export class TrinoClient extends BasicDatabaseClient { const sql = ` SELECT * - FROM ${this.db}.information_schema.columns - WHERE table_schema = '${schema}' - AND table_name = '${table}' + FROM ${this.wrapIdentifier(this.db)}.information_schema.columns + WHERE table_schema = '${escapeString(schema)}' + AND table_name = '${escapeString(table)}' ORDER BY ordinal_position ` const result = await this.driverExecuteSingle(sql) @@ -714,7 +714,7 @@ export class TrinoClient extends BasicDatabaseClient { SELECT ${wrappedSelects}, ROW_NUMBER() OVER (${rowNumberOrderClause}) AS rownum - FROM ${this.db}.${tableRef} + FROM ${this.wrapIdentifier(this.db)}.${tableRef} ${filter} ) SELECT * diff --git a/apps/studio/src/lib/db/clients/utils.ts b/apps/studio/src/lib/db/clients/utils.ts index f0ea7b457..551b9c493 100644 --- a/apps/studio/src/lib/db/clients/utils.ts +++ b/apps/studio/src/lib/db/clients/utils.ts @@ -48,17 +48,17 @@ export function buildSchemaFilter(filter, schemaField = 'schema_name') { const { schema, only, ignore } = filter if (schema) { - return `${schemaField} = '${schema}'`; + return `${schemaField} = '${escapeString(schema)}'`; } const where = []; if (only && only.length) { - where.push(`${schemaField} IN (${only.map((name) => `'${name}'`).join(',')})`); + where.push(`${schemaField} IN (${only.map((name) => `'${escapeString(name)}'`).join(',')})`); } if (ignore && ignore.length) { - where.push(`${schemaField} NOT IN (${ignore.map((name) => `'${name}'`).join(',')})`); + where.push(`${schemaField} NOT IN (${ignore.map((name) => `'${escapeString(name)}'`).join(',')})`); } return where.join(' AND '); diff --git a/apps/studio/tests/unit/lib/db/clients/trino.spec.ts b/apps/studio/tests/unit/lib/db/clients/trino.spec.ts index 1d3d7ca0f..7ff84edfd 100644 --- a/apps/studio/tests/unit/lib/db/clients/trino.spec.ts +++ b/apps/studio/tests/unit/lib/db/clients/trino.spec.ts @@ -1,11 +1,16 @@ +const capturedQueries: string[] = [] + jest.mock('trino-client', () => { - const mockQuery = jest.fn().mockResolvedValue({ - [Symbol.asyncIterator]: async function* () { - yield { - data: [['1.0.0']], - columns: [{ name: '_col0', type: 'varchar' }] + const mockQuery = jest.fn().mockImplementation((sql: string) => { + capturedQueries.push(sql) + return Promise.resolve({ + [Symbol.asyncIterator]: async function* () { + yield { + data: [['1.0.0']], + columns: [{ name: '_col0', type: 'varchar' }] + } } - } + }) }) return { @@ -139,3 +144,44 @@ describe('TrinoClient SSL configuration (bug #3695)', () => { expect(createCall.ssl).toBeUndefined() }) }) + +describe('TrinoClient SQL escaping', () => { + let client: TrinoClient + + beforeEach(async () => { + jest.clearAllMocks() + capturedQueries.length = 0 + client = new TrinoClient(makeServer(), makeDatabase()) + await client.connect() + capturedQueries.length = 0 + }) + + it('should wrap catalog name with identifier quoting in listSchemas', async () => { + const maliciousDb = "cat; DROP TABLE users --" + ;(client as any).db = maliciousDb + await client.listSchemas(null) + + const sql = capturedQueries[0] + // Catalog name must be inside double-quote identifiers + expect(sql).toContain('"cat; DROP TABLE users --"') + }) + + it('should wrap catalog name with identifier quoting in listTables', async () => { + const maliciousDb = "cat; DROP TABLE users --" + ;(client as any).db = maliciousDb + await client.listTables(null) + + const sql = capturedQueries[0] + // Catalog name must be inside double-quote identifiers + expect(sql).toContain('"cat; DROP TABLE users --".information_schema') + }) + + it('should escape schema and table names in listTableColumns', async () => { + await client.listTableColumns("test'; DROP TABLE users --", "public'; DROP TABLE users --") + + const sql = capturedQueries[0] + // Single quotes in values must be doubled to stay inside SQL string literals + expect(sql).toContain("public''; DROP TABLE users --") + expect(sql).toContain("test''; DROP TABLE users --") + }) +}) diff --git a/apps/studio/tests/unit/lib/db/clients/utils.spec.js b/apps/studio/tests/unit/lib/db/clients/utils.spec.js index 74de6aeec..75186ff1c 100644 --- a/apps/studio/tests/unit/lib/db/clients/utils.spec.js +++ b/apps/studio/tests/unit/lib/db/clients/utils.spec.js @@ -1,4 +1,4 @@ -import { buildSelectTopQuery, escapeString, isAllowedReadOnlyQuery } from "../../../../../src/lib/db/clients/utils"; +import { buildSchemaFilter, buildSelectTopQuery, escapeString, isAllowedReadOnlyQuery } from "../../../../../src/lib/db/clients/utils"; describe('Escape String', () => { it("should escape single quotes", () => { @@ -82,6 +82,31 @@ describe("buildSelectTopQuery", () => { }) }) +describe('buildSchemaFilter SQL injection', () => { + it("should escape single quotes in schema name to prevent SQL injection", () => { + const malicious = "'; DROP TABLE users; --" + const result = buildSchemaFilter({ schema: malicious }) + // The single quote must be doubled so the value stays inside the SQL string literal + // Safe: schema_name = '''; DROP TABLE users; --' (the '' is an escaped quote inside the literal) + // Unsafe: schema_name = ''; DROP TABLE users; --' (the ' closes the literal, rest is executable) + // Result: schema_name = '''; DROP TABLE users; --' + // In SQL: opening ', then '' (escaped quote), then rest of string, closing ' + // The whole value is treated as one string literal — safe. + expect(result).toBe("schema_name = '''; DROP TABLE users; --'") + }) + + it("should escape single quotes in 'only' filter values", () => { + const result = buildSchemaFilter({ only: ["public", "'; DROP TABLE users; --"] }) + expect(result).toContain("'public'") + expect(result).toContain("'''; DROP TABLE users; --'") + }) + + it("should escape single quotes in 'ignore' filter values", () => { + const result = buildSchemaFilter({ ignore: ["'; DROP TABLE users; --"] }) + expect(result).toContain("'''; DROP TABLE users; --'") + }) +}) + describe('isAllowedReadOnly', () => { it('Should return as a read only query', () => { const queries = [ From dbe3d594a9e8907a4e3c912ba7c0cc63c24921a1 Mon Sep 17 00:00:00 2001 From: Matthew Rathbone Date: Fri, 6 Mar 2026 15:00:59 -0600 Subject: [PATCH 07/13] refactor: use nginx load balancer for Trino SSL integration tests Replace direct Trino HTTPS configuration with nginx reverse proxy for TLS termination, following Trino docs best practices. Add http-server.process-forwarded=true to Trino config. Remove alpha warning from TrinoForm.vue. Co-Authored-By: Claude Opus 4.6 --- .../src/components/connection/TrinoForm.vue | 8 - .../integration/lib/db/clients/trino.spec.ts | 560 ++++++++---------- .../lib/db/clients/trino/container.ts | 205 ++++--- 3 files changed, 350 insertions(+), 423 deletions(-) diff --git a/apps/studio/src/components/connection/TrinoForm.vue b/apps/studio/src/components/connection/TrinoForm.vue index 2fbae3d04..f813e0aa7 100644 --- a/apps/studio/src/components/connection/TrinoForm.vue +++ b/apps/studio/src/components/connection/TrinoForm.vue @@ -1,13 +1,5 @@