From 6ace22da87306313f4fb4281e536e7664a2b8dcc Mon Sep 17 00:00:00 2001 From: Craig Rueda Date: Wed, 1 Nov 2023 16:52:14 -0700 Subject: [PATCH] chore(websocket): Adding support for redis username in websocket server (#25826) --- superset-websocket/config.test.json | 5 +-- superset-websocket/package-lock.json | 46 ++++++++++++++---------- superset-websocket/package.json | 2 ++ superset-websocket/spec/config.test.ts | 10 +++++- superset-websocket/spec/index.test.ts | 29 ++++++++++++---- superset-websocket/src/config.ts | 26 +++++++++----- superset-websocket/src/index.ts | 48 ++++++++++++++++++-------- 7 files changed, 114 insertions(+), 52 deletions(-) diff --git a/superset-websocket/config.test.json b/superset-websocket/config.test.json index ef0653dea2..8557846537 100644 --- a/superset-websocket/config.test.json +++ b/superset-websocket/config.test.json @@ -1,10 +1,7 @@ { "redis": { - "port": 6379, - "host": "127.0.0.1", - "password": "", "db": 10, - "ssl": false + "password": "some pwd" }, "statsd": { "host": "127.0.0.1", diff --git a/superset-websocket/package-lock.json b/superset-websocket/package-lock.json index ebecd6c95d..c0bb4e6f6f 100644 --- a/superset-websocket/package-lock.json +++ b/superset-websocket/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { + "@types/lodash": "^4.14.200", "cookie": "^0.5.0", "hot-shots": "^10.0.0", "ioredis": "^4.28.0", "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", "uuid": "^9.0.1", "winston": "^3.11.0", "ws": "^8.14.2" @@ -1421,6 +1423,11 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.14.200", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.200.tgz", + "integrity": "sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==" + }, "node_modules/@types/node": { "version": "20.8.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz", @@ -2431,9 +2438,9 @@ } }, "node_modules/denque": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", - "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", "engines": { "node": ">=0.10" } @@ -3491,9 +3498,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ioredis": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.0.tgz", - "integrity": "sha512-I+zkeeWp3XFgPT2CtJKxvaF5FjGBGt4yGYljRjQecdQKteThuAsKqffeF1lgHVlYnuNeozRbPOCDNZ7tDWPeig==", + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.5.tgz", + "integrity": "sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==", "dependencies": { "cluster-key-slot": "^1.1.0", "debug": "^4.3.1", @@ -4530,8 +4537,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.defaults": { "version": "4.2.0", @@ -4541,7 +4547,7 @@ "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==" }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -7271,6 +7277,11 @@ "@types/node": "*" } }, + "@types/lodash": { + "version": "4.14.200", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.200.tgz", + "integrity": "sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==" + }, "@types/node": { "version": "20.8.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz", @@ -8029,9 +8040,9 @@ "dev": true }, "denque": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", - "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==" + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==" }, "detect-newline": { "version": "3.1.0", @@ -8816,9 +8827,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ioredis": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.0.tgz", - "integrity": "sha512-I+zkeeWp3XFgPT2CtJKxvaF5FjGBGt4yGYljRjQecdQKteThuAsKqffeF1lgHVlYnuNeozRbPOCDNZ7tDWPeig==", + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.5.tgz", + "integrity": "sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==", "requires": { "cluster-key-slot": "^1.1.0", "debug": "^4.3.1", @@ -9613,8 +9624,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.defaults": { "version": "4.2.0", @@ -9624,7 +9634,7 @@ "lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==" }, "lodash.includes": { "version": "4.3.0", diff --git a/superset-websocket/package.json b/superset-websocket/package.json index 090cfa1749..a25850b600 100644 --- a/superset-websocket/package.json +++ b/superset-websocket/package.json @@ -16,10 +16,12 @@ }, "license": "Apache-2.0", "dependencies": { + "@types/lodash": "^4.14.200", "cookie": "^0.5.0", "hot-shots": "^10.0.0", "ioredis": "^4.28.0", "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", "uuid": "^9.0.1", "winston": "^3.11.0", "ws": "^8.14.2" diff --git a/superset-websocket/spec/config.test.ts b/superset-websocket/spec/config.test.ts index 72e592dfde..9df8710190 100644 --- a/superset-websocket/spec/config.test.ts +++ b/superset-websocket/spec/config.test.ts @@ -26,7 +26,7 @@ test('buildConfig() builds configuration and applies env var overrides', () => { ); expect(config.redis.host).toEqual('127.0.0.1'); expect(config.redis.port).toEqual(6379); - expect(config.redis.password).toEqual(''); + expect(config.redis.password).toEqual('some pwd'); expect(config.redis.db).toEqual(10); expect(config.redis.ssl).toEqual(false); expect(config.statsd.host).toEqual('127.0.0.1'); @@ -65,3 +65,11 @@ test('buildConfig() builds configuration and applies env var overrides', () => { delete process.env.STATSD_PORT; delete process.env.STATSD_GLOBAL_TAGS; }); + +test('buildConfig() performs deep merge between configs', () => { + const config = buildConfig(); + // We left the ssl setting the default + expect(config.redis.ssl).toEqual(false); + // We overrode the pwd + expect(config.redis.password).toEqual('some pwd'); +}); diff --git a/superset-websocket/spec/index.test.ts b/superset-websocket/spec/index.test.ts index ca575e9e8a..1eb6e7cc52 100644 --- a/superset-websocket/spec/index.test.ts +++ b/superset-websocket/spec/index.test.ts @@ -138,36 +138,53 @@ describe('server', () => { describe('redisUrlFromConfig', () => { test('it builds a valid Redis URL from defaults', () => { expect( - server.redisUrlFromConfig({ + server.buildRedisOpts({ port: 6379, host: '127.0.0.1', + username: 'test-user', password: '', db: 0, ssl: false, + validateHostname: false, }), - ).toEqual('redis://127.0.0.1:6379/0'); + ).toEqual({ db: 0, host: '127.0.0.1', port: 6379 }); }); test('it builds a valid Redis URL with a password', () => { expect( - server.redisUrlFromConfig({ + server.buildRedisOpts({ port: 6380, host: 'redis.local', + username: 'cool-user', password: 'foo', db: 1, ssl: false, + validateHostname: false, }), - ).toEqual('redis://:foo@redis.local:6380/1'); + ).toEqual({ + db: 1, + host: 'redis.local', + password: 'foo', + port: 6380, + username: 'cool-user', + }); }); test('it builds a valid Redis URL with SSL', () => { expect( - server.redisUrlFromConfig({ + server.buildRedisOpts({ port: 6379, host: '127.0.0.1', password: '', + username: 'cool-user', db: 0, ssl: true, + validateHostname: false, }), - ).toEqual('rediss://127.0.0.1:6379/0'); + ).toEqual({ + db: 0, + host: '127.0.0.1', + port: 6379, + tls: { checkServerIdentity: expect.anything() }, + }); }); }); diff --git a/superset-websocket/src/config.ts b/superset-websocket/src/config.ts index aa361d17e2..7c25048928 100644 --- a/superset-websocket/src/config.ts +++ b/superset-websocket/src/config.ts @@ -16,6 +16,19 @@ * specific language governing permissions and limitations * under the License. */ + +import * as _ from 'lodash'; + +export interface RedisConfig { + port: number; + host: string; + password: string; + username: string; + db: number; + ssl: boolean; + validateHostname: boolean; +} + type ConfigType = { port: number; logLevel: string; @@ -26,13 +39,7 @@ type ConfigType = { port: number; globalTags: Array; }; - redis: { - port: number; - host: string; - password: string; - db: number; - ssl: boolean; - }; + redis: RedisConfig; redisStreamPrefix: string; redisStreamReadCount: number; redisStreamReadBlockMs: number; @@ -70,8 +77,10 @@ function defaultConfig(): ConfigType { host: '127.0.0.1', port: 6379, password: '', + username: 'default', db: 0, ssl: false, + validateHostname: true, }, }; } @@ -114,6 +123,7 @@ function applyEnvOverrides(config: ConfigType): ConfigType { REDIS_HOST: val => (config.redis.host = val), REDIS_PORT: val => (config.redis.port = toNumber(val)), REDIS_PASSWORD: val => (config.redis.password = val), + REDIS_USERNAME: val => (config.redis.username = val), REDIS_DB: val => (config.redis.db = toNumber(val)), REDIS_SSL: val => (config.redis.ssl = toBoolean(val)), STATSD_HOST: val => (config.statsd.host = val), @@ -132,6 +142,6 @@ function applyEnvOverrides(config: ConfigType): ConfigType { } export function buildConfig(): ConfigType { - const config = Object.assign(defaultConfig(), configFromFile()); + const config = _.merge(defaultConfig(), configFromFile()); return applyEnvOverrides(config); } diff --git a/superset-websocket/src/index.ts b/superset-websocket/src/index.ts index cd73a6baa6..ced46f18bb 100644 --- a/superset-websocket/src/index.ts +++ b/superset-websocket/src/index.ts @@ -22,11 +22,12 @@ import WebSocket from 'ws'; import { v4 as uuidv4 } from 'uuid'; import jwt, { Algorithm } from 'jsonwebtoken'; import cookie from 'cookie'; -import Redis from 'ioredis'; +import Redis, { RedisOptions } from 'ioredis'; import StatsD from 'hot-shots'; import { createLogger } from './logger'; -import { buildConfig } from './config'; +import { buildConfig, RedisConfig } from './config'; +import { checkServerIdentity, PeerCertificate } from 'tls'; export type StreamResult = [ recordId: string, @@ -66,13 +67,6 @@ export interface SocketInstance { channel: string; pongTs: number; } -interface RedisConfig { - port: number; - host: string; - password?: string | null; - db: number; - ssl: boolean; -} interface ChannelValue { sockets: Array; @@ -103,15 +97,39 @@ export const statsd = new StatsD({ if (startServer && opts.jwtSecret.length < 32) throw new Error('Please provide a JWT secret at least 32 bytes long'); -export const redisUrlFromConfig = (redisConfig: RedisConfig): string => { - let url = redisConfig.ssl ? 'rediss://' : 'redis://'; - if (redisConfig.password) url += `:${redisConfig.password}@`; - url += `${redisConfig.host}:${redisConfig.port}/${redisConfig.db}`; - return url; +export const buildRedisOpts = (baseConfig: RedisConfig) => { + const redisOpts: RedisOptions = { + port: baseConfig.port, + host: baseConfig.host, + db: baseConfig.db, + }; + + const passwd = baseConfig.password; + if (passwd !== '') { + redisOpts.username = baseConfig.username; + redisOpts.password = baseConfig.password; + } + + if (baseConfig.ssl) { + redisOpts.tls = { + checkServerIdentity: ( + hostname: string, + cert: PeerCertificate, + ): Error | undefined => { + // Note, the cert chain will have been verified already. the role of this method is to + // validate that at least one of the SAN's (or subject) of the server's cert matches the provided hostname + if (baseConfig.validateHostname) { + return checkServerIdentity(hostname, cert); + } + }, + }; + } + + return redisOpts; }; // initialize servers -const redis = new Redis(redisUrlFromConfig(opts.redis)); +const redis = new Redis(buildRedisOpts(opts.redis)); const httpServer = http.createServer(); export const wss = new WebSocket.Server({ noServer: true,