Skip to content

Commit

Permalink
Extend connection options (#181)
Browse files Browse the repository at this point in the history
* Restructure to allow rejecting IP ranges for all connections.

* Fix for tests.

* Don't care about the error message.

* Cleanup.

* Add changesets.

* Add jwks_reject_ip_ranges instead of repurposing block_local_jwks.
  • Loading branch information
rkistner authored Jan 21, 2025
1 parent 9befbfc commit 8675236
Show file tree
Hide file tree
Showing 33 changed files with 489 additions and 277 deletions.
5 changes: 5 additions & 0 deletions .changeset/friendly-adults-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/service-core': minor
---

Support IPv6 for JWKS URI.
5 changes: 5 additions & 0 deletions .changeset/old-ducks-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/service-types': patch
---

Minor config changes.
11 changes: 11 additions & 0 deletions .changeset/thick-paws-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@powersync/service-module-postgres': minor
'@powersync/service-module-mongodb': minor
'@powersync/service-core': minor
'@powersync/service-module-mysql': minor
'@powersync/lib-service-postgres': minor
'@powersync/lib-services-framework': minor
'@powersync/lib-service-mongodb': minor
---

Allow limiting IP ranges of outgoing connections
5 changes: 5 additions & 0 deletions .changeset/warm-numbers-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/service-jpgwire': minor
---

Allow custom lookup function and tls_servername. Also reduce dependencies.
3 changes: 3 additions & 0 deletions libs/lib-mongodb/src/db/mongo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as mongo from 'mongodb';
import * as timers from 'timers/promises';
import { BaseMongoConfigDecoded, normalizeMongoConfig } from '../types/types.js';
import { validateIpHostname } from '@powersync/lib-services-framework';

/**
* Time for new connection to timeout.
Expand Down Expand Up @@ -42,6 +43,8 @@ export function createMongoClient(config: BaseMongoConfigDecoded) {
// How long to wait for new primary selection
serverSelectionTimeoutMS: 30_000,

lookup: normalized.lookup,

// Avoid too many connections:
// 1. It can overwhelm the source database.
// 2. Processing too many queries in parallel can cause the process to run out of memory.
Expand Down
14 changes: 12 additions & 2 deletions libs/lib-mongodb/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LookupOptions, makeHostnameLookupFunction } from '@powersync/lib-services-framework';
import * as t from 'ts-codec';
import * as urijs from 'uri-js';

Expand All @@ -8,7 +9,9 @@ export const BaseMongoConfig = t.object({
uri: t.string,
database: t.string.optional(),
username: t.string.optional(),
password: t.string.optional()
password: t.string.optional(),

reject_ip_ranges: t.array(t.string).optional()
});

export type BaseMongoConfig = t.Encoded<typeof BaseMongoConfig>;
Expand Down Expand Up @@ -46,11 +49,18 @@ export function normalizeMongoConfig(options: BaseMongoConfigDecoded) {

delete uri.userinfo;

const lookupOptions: LookupOptions = {
reject_ip_ranges: options.reject_ip_ranges ?? []
};
const lookup = makeHostnameLookupFunction(uri.host ?? '', lookupOptions);

return {
uri: urijs.serialize(uri),
database,

username,
password
password,

lookup
};
}
1 change: 0 additions & 1 deletion libs/lib-postgres/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"dependencies": {
"@powersync/lib-services-framework": "workspace:*",
"@powersync/service-jpgwire": "workspace:*",
"@powersync/service-sync-rules": "workspace:*",
"@powersync/service-types": "workspace:*",
"p-defer": "^4.0.1",
"ts-codec": "^1.3.0",
Expand Down
37 changes: 18 additions & 19 deletions libs/lib-postgres/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,10 @@
import { makeHostnameLookupFunction } from '@powersync/lib-services-framework';
import type * as jpgwire from '@powersync/service-jpgwire';
import * as service_types from '@powersync/service-types';
import * as t from 'ts-codec';
import * as urijs from 'uri-js';

export interface NormalizedBasePostgresConnectionConfig {
id: string;
tag: string;

hostname: string;
port: number;
database: string;

username: string;
password: string;

sslmode: 'verify-full' | 'verify-ca' | 'disable';
cacert: string | undefined;

client_certificate: string | undefined;
client_private_key: string | undefined;
}
export interface NormalizedBasePostgresConnectionConfig extends jpgwire.NormalizedConnectionConfig {}

export const POSTGRES_CONNECTION_TYPE = 'postgresql' as const;

Expand All @@ -43,8 +29,15 @@ export const BasePostgresConnectionConfig = t.object({
client_certificate: t.string.optional(),
client_private_key: t.string.optional(),

/** Expose database credentials */
demo_database: t.boolean.optional(),
/** Specify to use a servername for TLS that is different from hostname. */
tls_servername: t.string.optional(),

/**
* Block connections in any of these IP ranges.
*
* Use 'local' to block anything not in public unicast ranges.
*/
reject_ip_ranges: t.array(t.string).optional(),

/**
* Prefix for the slot name. Defaults to "powersync_"
Expand Down Expand Up @@ -106,6 +99,9 @@ export function normalizeConnectionConfig(options: BasePostgresConnectionConfigD
throw new Error(`database required`);
}

const lookupOptions = { reject_ip_ranges: options.reject_ip_ranges ?? [] };
const lookup = makeHostnameLookupFunction(hostname, lookupOptions);

return {
id: options.id ?? 'default',
tag: options.tag ?? 'default',
Expand All @@ -119,6 +115,9 @@ export function normalizeConnectionConfig(options: BasePostgresConnectionConfigD
sslmode,
cacert,

tls_servername: options.tls_servername ?? undefined,
lookup,

client_certificate: options.client_certificate ?? undefined,
client_private_key: options.client_private_key ?? undefined
} satisfies NormalizedBasePostgresConnectionConfig;
Expand Down
3 changes: 1 addition & 2 deletions libs/lib-postgres/src/utils/pgwire_utils.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
// Adapted from https://github.com/kagis/pgwire/blob/0dc927f9f8990a903f238737326e53ba1c8d094f/mod.js#L2218

import * as pgwire from '@powersync/service-jpgwire';
import { SqliteJsonValue } from '@powersync/service-sync-rules';

import { logger } from '@powersync/lib-services-framework';

export function escapeIdentifier(identifier: string) {
return `"${identifier.replace(/"/g, '""').replace(/\./g, '"."')}"`;
}

export function autoParameter(arg: SqliteJsonValue | boolean): pgwire.StatementParam {
export function autoParameter(arg: any): pgwire.StatementParam {
if (arg == null) {
return { type: 'varchar', value: null };
} else if (typeof arg == 'string') {
Expand Down
2 changes: 1 addition & 1 deletion libs/lib-postgres/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"sourceMap": true
},
"include": ["src"],
"references": []
"references": [{ "path": "../../packages/jpgwire" }, { "path": "../lib-services" }]
}
1 change: 1 addition & 0 deletions libs/lib-services/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"better-ajv-errors": "^1.2.0",
"bson": "^6.8.0",
"dotenv": "^16.4.5",
"ipaddr.js": "^2.1.0",
"lodash": "^4.17.21",
"ts-codec": "^1.3.0",
"uuid": "^9.0.1",
Expand Down
3 changes: 3 additions & 0 deletions libs/lib-services/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ export * as system from './system/system-index.js';

export * from './utils/utils-index.js';
export * as utils from './utils/utils-index.js';

export * from './ip/ip-index.js';
export * as ip from './ip/ip-index.js';
1 change: 1 addition & 0 deletions libs/lib-services/src/ip/ip-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lookup.js';
118 changes: 118 additions & 0 deletions libs/lib-services/src/ip/lookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import * as net from 'node:net';
import * as dns from 'node:dns';
import * as dnsp from 'node:dns/promises';
import ip from 'ipaddr.js';

export interface LookupOptions {
reject_ip_ranges: string[];
reject_ipv6?: boolean;
}

/**
* Generate a custom DNS lookup function, that rejects specific IP ranges.
*
* If hostname is an IP, this synchronously validates it.
*
* @returns a function to use as the `lookup` option in `net.connect`.
*/
export function makeHostnameLookupFunction(
hostname: string,
lookupOptions: LookupOptions
): net.LookupFunction | undefined {
validateIpHostname(hostname, lookupOptions);
return makeLookupFunction(lookupOptions);
}

/**
* Generate a custom DNS lookup function, that rejects specific IP ranges.
*
* Note: Lookup functions are not used for IPs configured directly.
* For those, validate the IP directly using validateIpHostname().
*
* @param reject_ip_ranges IPv4 and/or IPv6 subnets to reject, or 'local' to reject any IP that isn't public unicast.
* @returns a function to use as the `lookup` option in `net.connect`, or undefined if no restrictions are applied.
*/
export function makeLookupFunction(lookupOptions: LookupOptions): net.LookupFunction | undefined {
if (lookupOptions.reject_ip_ranges.length == 0 && !lookupOptions.reject_ipv6) {
// No restrictions - use the default behavior
return undefined;
}
return (hostname, options, callback) => {
resolveIp(hostname, lookupOptions)
.then((resolvedAddress) => {
if (options.all) {
callback(null, [resolvedAddress]);
} else {
callback(null, resolvedAddress.address, resolvedAddress.family);
}
})
.catch((err) => {
callback(err, undefined as any, undefined);
});
};
}

/**
* Validate IPs synchronously.
*
* If the hostname is not an ip, this does nothing.
*
* @param hostname IP or DNS name
* @param options
*/
export function validateIpHostname(hostname: string, options: LookupOptions): void {
const { reject_ip_ranges: reject_ranges } = options;
if (!ip.isValid(hostname)) {
// Treat as a DNS name.
return;
}

const parsed = ip.parse(hostname);
const rejectLocal = reject_ranges.includes('local');
const rejectSubnets = reject_ranges.filter((range) => range != 'local');

const reject = { blocked: (rejectSubnets ?? []).map((r) => ip.parseCIDR(r)) };

if (options.reject_ipv6 && parsed.kind() == 'ipv6') {
throw new Error('IPv6 not supported');
}

if (ip.subnetMatch(parsed, reject) == 'blocked') {
// Ranges explicitly blocked, e.g. private IPv6 ranges
throw new Error(`IPs in this range are not supported: ${hostname}`);
}

if (!rejectLocal) {
return;
}

if (parsed.kind() == 'ipv4' && parsed.range() == 'unicast') {
// IPv4 - All good
return;
} else if (parsed.kind() == 'ipv6' && parsed.range() == 'unicast') {
// IPv6 - All good
return;
} else {
// Do not connect to any reserved IPs, including loopback and private ranges
throw new Error(`IPs in this range are not supported: ${hostname}`);
}
}

/**
* Resolve IP, and check that it is in an allowed range.
*/
export async function resolveIp(hostname: string, options: LookupOptions): Promise<dns.LookupAddress> {
let resolvedAddress: dns.LookupAddress;
if (net.isIPv4(hostname)) {
// Direct ipv4 - all good so far
resolvedAddress = { address: hostname, family: 4 };
} else if (net.isIPv6(hostname) || net.isIPv4(hostname)) {
// Direct ipv6 - all good so far
resolvedAddress = { address: hostname, family: 6 };
} else {
// DNS name - resolve it
resolvedAddress = await dnsp.lookup(hostname);
}
validateIpHostname(resolvedAddress.address, options);
return resolvedAddress;
}
2 changes: 2 additions & 0 deletions modules/module-mongodb/src/replication/MongoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export class MongoManager {
username: options.username,
password: options.password
},

lookup: options.lookup,
// Time for connection to timeout
connectTimeoutMS: 5_000,
// Time for individual requests to timeout
Expand Down
3 changes: 3 additions & 0 deletions modules/module-mongodb/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as lib_mongo from '@powersync/lib-service-mongodb/types';
import * as service_types from '@powersync/service-types';
import { LookupFunction } from 'node:net';
import * as t from 'ts-codec';

export enum PostImagesOption {
Expand Down Expand Up @@ -48,6 +49,8 @@ export interface NormalizedMongoConnectionConfig {
username?: string;
password?: string;

lookup?: LookupFunction;

postImages: PostImagesOption;
}

Expand Down
15 changes: 13 additions & 2 deletions modules/module-mysql/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { makeHostnameLookupFunction } from '@powersync/lib-services-framework';
import * as service_types from '@powersync/service-types';
import { reject } from 'async';
import { LookupFunction } from 'node:net';
import * as t from 'ts-codec';
import * as urijs from 'uri-js';

Expand All @@ -19,6 +22,8 @@ export interface NormalizedMySQLConnectionConfig {
cacert?: string;
client_certificate?: string;
client_private_key?: string;

lookup?: LookupFunction;
}

export const MySQLConnectionConfig = service_types.configFile.DataSourceConfig.and(
Expand All @@ -34,7 +39,9 @@ export const MySQLConnectionConfig = service_types.configFile.DataSourceConfig.a

cacert: t.string.optional(),
client_certificate: t.string.optional(),
client_private_key: t.string.optional()
client_private_key: t.string.optional(),

reject_ip_ranges: t.array(t.string).optional()
})
);

Expand Down Expand Up @@ -90,6 +97,8 @@ export function normalizeConnectionConfig(options: MySQLConnectionConfig): Norma
throw new Error(`database required`);
}

const lookup = makeHostnameLookupFunction(hostname, { reject_ip_ranges: options.reject_ip_ranges ?? [] });

return {
id: options.id ?? 'default',
tag: options.tag ?? 'default',
Expand All @@ -101,6 +110,8 @@ export function normalizeConnectionConfig(options: MySQLConnectionConfig): Norma
username,
password,

server_id: options.server_id ?? 1
server_id: options.server_id ?? 1,

lookup
};
}
1 change: 1 addition & 0 deletions modules/module-mysql/src/utils/mysql-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function createPool(config: types.NormalizedMySQLConnectionConfig, option
cert: config.client_certificate
};
const hasSSLOptions = Object.values(sslOptions).some((v) => !!v);
// TODO: Use config.lookup for DNS resolution
return mysql.createPool({
host: config.hostname,
user: config.username,
Expand Down
Loading

0 comments on commit 8675236

Please sign in to comment.