Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/config-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { parseString } from 'xml2js';
import { promisify } from 'util';
import crypto from 'crypto';
import { DatabaseConnection, WorkspaceConfig } from './types.js';
import { parseConnectionId } from './utils.js';

const parseXML = promisify(parseString);

Expand Down Expand Up @@ -273,10 +274,23 @@ export class WorkspaceConfigParser {

async getConnection(connectionId: string): Promise<DatabaseConnection | null> {
try {
const { baseId, databaseOverride } = parseConnectionId(connectionId);
const connections = await this.parseConnections();
return (
connections.find((conn) => conn.id === connectionId || conn.name === connectionId) || null
);
const match = connections.find((conn) => conn.id === baseId || conn.name === baseId) || null;

if (!match || !databaseOverride) {
return match;
}

// Database-override syntax "<id>/<database>": clone the matched connection
// with the requested database swapped in. The synthetic id ensures pool
// caches keyed on connection.id stay separated per target database.
return {
...match,
id: `${match.id}/${databaseOverride}`,
database: databaseOverride,
properties: { ...(match.properties || {}), database: databaseOverride },
};
} catch (error) {
if (this.config.debug) {
console.error(`Failed to get connection ${connectionId}: ${error}`);
Expand Down
37 changes: 34 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,20 @@ export function enforceReadOnly(query: string): string | null {
}

/**
* Sanitize connection ID to prevent injection
* Sanitize connection ID to prevent injection.
*
* Allows an optional database-override suffix using the syntax
* `<connectionIdOrName>/<database>`. The forward slash is preserved so that
* `parseConnectionId` (and downstream lookup) can split it back out.
*/
export function sanitizeConnectionId(connectionId: string): string {
if (!connectionId || typeof connectionId !== 'string') {
throw new Error('Connection ID must be a non-empty string');
}

// Remove potentially dangerous characters
const sanitized = connectionId.replace(/[^a-zA-Z0-9_\-.]/g, '');
// Remove potentially dangerous characters; '/' is allowed for the database
// override syntax "<id>/<database>" and is split out by parseConnectionId.
const sanitized = connectionId.replace(/[^a-zA-Z0-9_\-./]/g, '');

if (sanitized.length === 0) {
throw new Error('Connection ID contains no valid characters');
Expand All @@ -112,6 +117,32 @@ export function sanitizeConnectionId(connectionId: string): string {
return sanitized;
}

/**
* Split an incoming connection identifier into its base ID/name and an optional
* database-override component. Returns `databaseOverride: null` when no
* override is present.
*
* Examples:
* "my-conn" -> { baseId: "my-conn", databaseOverride: null }
* "my-conn/analytics" -> { baseId: "my-conn", databaseOverride: "analytics" }
* "postgres-jdbc-abc/orders" -> { baseId: "postgres-jdbc-abc", databaseOverride: "orders" }
*/
export function parseConnectionId(connectionId: string): {
baseId: string;
databaseOverride: string | null;
} {
const slashIdx = connectionId.indexOf('/');
if (slashIdx < 0) {
return { baseId: connectionId, databaseOverride: null };
}
const baseId = connectionId.slice(0, slashIdx);
const databaseOverride = connectionId.slice(slashIdx + 1);
if (!baseId || !databaseOverride) {
return { baseId: connectionId, databaseOverride: null };
}
return { baseId, databaseOverride };
}

/**
* Sanitize SQL identifier (table name, schema name, column name)
* Escapes single quotes and validates the identifier
Expand Down
68 changes: 68 additions & 0 deletions tests/config-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,72 @@ describe('WorkspaceConfigParser', () => {
expect(connections).toEqual([]);
});
});

describe('getConnection database-override syntax', () => {
const baseConnection = {
id: 'conn-1',
name: 'my-conn',
driver: 'postgres-jdbc',
url: 'jdbc:postgresql://host:5432/postgres',
host: 'host',
port: 5432,
database: 'postgres',
user: 'app',
properties: { user: 'app', host: 'host', database: 'postgres', sslmode: 'require' },
};

it('should look up by base id when no slash is present', async () => {
const { WorkspaceConfigParser } = await import('../src/config-parser.js');
const parser = new WorkspaceConfigParser({});
vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]);

const result = await parser.getConnection('conn-1');
expect(result).toEqual(baseConnection);
});

it('should look up by name when no slash is present', async () => {
const { WorkspaceConfigParser } = await import('../src/config-parser.js');
const parser = new WorkspaceConfigParser({});
vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]);

const result = await parser.getConnection('my-conn');
expect(result).toEqual(baseConnection);
});

it('should override database when "<id>/<database>" syntax is used', async () => {
const { WorkspaceConfigParser } = await import('../src/config-parser.js');
const parser = new WorkspaceConfigParser({});
vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]);

const result = await parser.getConnection('conn-1/analytics');
expect(result).not.toBeNull();
expect(result!.id).toBe('conn-1/analytics');
expect(result!.database).toBe('analytics');
expect(result!.properties?.database).toBe('analytics');
// Other fields remain intact
expect(result!.host).toBe('host');
expect(result!.user).toBe('app');
expect(result!.properties?.sslmode).toBe('require');
});

it('should not mutate the cached connection when overriding', async () => {
const { WorkspaceConfigParser } = await import('../src/config-parser.js');
const parser = new WorkspaceConfigParser({});
vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]);

await parser.getConnection('conn-1/analytics');
expect(baseConnection.id).toBe('conn-1');
expect(baseConnection.database).toBe('postgres');
expect(baseConnection.properties.database).toBe('postgres');
});

it('should return null when the base id does not match', async () => {
const { WorkspaceConfigParser } = await import('../src/config-parser.js');
const parser = new WorkspaceConfigParser({});
vi.spyOn(parser, 'parseConnections').mockResolvedValue([baseConnection]);

const result = await parser.getConnection('does-not-exist/analytics');
expect(result).toBeNull();
});
});
});
40 changes: 40 additions & 0 deletions tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
sanitizeConnectionId,
sanitizeIdentifier,
getTestQuery,
parseConnectionId,
} from '../src/utils.js';

describe('validateQuery', () => {
Expand Down Expand Up @@ -130,6 +131,45 @@ describe('sanitizeConnectionId', () => {
expect(sanitizeConnectionId('conn;DROP TABLE')).toBe('connDROPTABLE');
expect(sanitizeConnectionId('test<script>')).toBe('testscript');
});

it('should preserve "/" for the database-override suffix', () => {
expect(sanitizeConnectionId('my-conn/analytics')).toBe('my-conn/analytics');
expect(sanitizeConnectionId('postgres-jdbc-abc/orders')).toBe('postgres-jdbc-abc/orders');
});
});

describe('parseConnectionId', () => {
it('should return baseId and null override when no slash is present', () => {
expect(parseConnectionId('my-conn')).toEqual({
baseId: 'my-conn',
databaseOverride: null,
});
});

it('should split on the first slash into base and override', () => {
expect(parseConnectionId('my-conn/analytics')).toEqual({
baseId: 'my-conn',
databaseOverride: 'analytics',
});
});

it('should treat empty halves as no override', () => {
expect(parseConnectionId('/analytics')).toEqual({
baseId: '/analytics',
databaseOverride: null,
});
expect(parseConnectionId('my-conn/')).toEqual({
baseId: 'my-conn/',
databaseOverride: null,
});
});

it('should preserve later slashes inside the database name', () => {
expect(parseConnectionId('my-conn/foo/bar')).toEqual({
baseId: 'my-conn',
databaseOverride: 'foo/bar',
});
});
});

describe('sanitizeIdentifier', () => {
Expand Down