Skip to content
Merged
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
16 changes: 15 additions & 1 deletion apps/public/content/docs/self-hosting/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -703,13 +703,27 @@ Port for the worker service to listen on.
WORKER_PORT=3000
```

### BULLBOARD_USERNAME / BULLBOARD_PASSWORD

**Type**: `string`
**Required**: No
**Default**: None

Credentials for the Bull Board queue dashboard served on the worker port. The dashboard can add, retry and clean jobs, so it is only mounted when both variables are set. Without them the worker logs a warning at startup and serves only `/healthcheck` and `/metrics`. The browser prompts for these credentials (HTTP Basic auth).

**Example**:
```bash
BULLBOARD_USERNAME=ops
BULLBOARD_PASSWORD=$(openssl rand -hex 16)
```

### DISABLE_BULLBOARD

**Type**: `boolean`
**Required**: No
**Default**: `false`

Disable BullMQ board UI. Set to `true` or `1` to disable the queue monitoring dashboard.
Disable the Bull Board queue dashboard entirely and silence the startup warning about missing credentials. Set to `true` or `1`.

**Example**:
```bash
Expand Down
70 changes: 45 additions & 25 deletions apps/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { bootCron } from './boot-cron';
import { bootDebugRoutes } from './boot-debug';
import { bootWorkers } from './boot-workers';
import { register } from './metrics';
import { basicAuth } from './utils/basic-auth';
import { isShuttingDown } from './utils/graceful-shutdown';
import { logger } from './utils/logger';
import { getEventsHeartbeat } from './utils/worker-heartbeat';
Expand All @@ -45,31 +46,6 @@ async function start() {
bootDebugRoutes(app);
}

if (
process.env.DISABLE_BULLBOARD !== '1' &&
process.env.DISABLE_BULLBOARD !== 'true'
) {
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/');
createBullBoard({
queues: [
...eventsGroupQueues.map(
(queue) => new BullBoardGroupMQAdapter(queue) as any
),
new BullMQAdapter(sessionsQueue),
new BullMQAdapter(cronQueue),
new BullMQAdapter(notificationQueue),
new BullMQAdapter(importQueue),
new BullMQAdapter(insightsQueue),
new BullMQAdapter(gscQueue),
new BullMQAdapter(cohortComputeQueue),
],
serverAdapter,
});

app.use('/', serverAdapter.getRouter());
}

app.get('/metrics', (req, res) => {
res.set('Content-Type', register.contentType);
register
Expand Down Expand Up @@ -127,6 +103,50 @@ async function start() {
failedDependencies,
workingDependencies,
});

// Bull Board exposes every queue with add/retry/clean enabled, so it never
// mounts without credentials (GHSA-r627-6vrh-65p9). /metrics and
// /healthcheck are registered above so the auth guard does not cover them.
const bullboardDisabled =
process.env.DISABLE_BULLBOARD === '1' ||
process.env.DISABLE_BULLBOARD === 'true';
const bullboardUsername = process.env.BULLBOARD_USERNAME;
const bullboardPassword = process.env.BULLBOARD_PASSWORD;

const hasBullboardCredentials = Boolean(bullboardUsername && bullboardPassword);

if (!bullboardDisabled && !hasBullboardCredentials) {
logger.warn(
'Bull Board is not mounted: set BULLBOARD_USERNAME and BULLBOARD_PASSWORD to enable the queue dashboard, or DISABLE_BULLBOARD=true to silence this warning',
);
}

if (!bullboardDisabled && bullboardUsername && bullboardPassword) {
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/');
createBullBoard({
queues: [
...eventsGroupQueues.map(
(queue) => new BullBoardGroupMQAdapter(queue) as any
),
new BullMQAdapter(sessionsQueue),
new BullMQAdapter(cronQueue),
new BullMQAdapter(notificationQueue),
new BullMQAdapter(importQueue),
new BullMQAdapter(insightsQueue),
new BullMQAdapter(gscQueue),
new BullMQAdapter(cohortComputeQueue),
],
serverAdapter,
});

app.use(
'/',
basicAuth(bullboardUsername, bullboardPassword),
serverAdapter.getRouter(),
);
}

});

// Kubernetes liveness — shallow, event loop only.
Expand Down
43 changes: 43 additions & 0 deletions apps/worker/src/utils/basic-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { basicAuth } from './basic-auth';

function run(header?: string) {
const req = { headers: { authorization: header } } as never;
const res = { set: vi.fn(), status: vi.fn().mockReturnThis(), send: vi.fn() };
const next = vi.fn();
basicAuth('ops', 's3cret')(req, res as never, next);
return { res, next };
}

const encode = (s: string) => `Basic ${Buffer.from(s).toString('base64')}`;

describe('basicAuth', () => {
it('lets the right credentials through', () => {
const { next, res } = run(encode('ops:s3cret'));
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

it('rejects a missing header', () => {
const { next, res } = run(undefined);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Basic'));
});

it('rejects a wrong password, wrong user and malformed value', () => {
for (const header of [encode('ops:nope'), encode('root:s3cret'), encode('no-colon'), 'Bearer abc']) {
const { next, res } = run(header);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
}
});

it('allows a colon inside the password', () => {
const req = { headers: { authorization: encode('ops:a:b') } } as never;
const res = { set: vi.fn(), status: vi.fn().mockReturnThis(), send: vi.fn() };
const next = vi.fn();
basicAuth('ops', 'a:b')(req, res as never, next);
expect(next).toHaveBeenCalled();
});
});
34 changes: 34 additions & 0 deletions apps/worker/src/utils/basic-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { timingSafeEqual } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';

function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
}

/**
* HTTP Basic auth for operator-only routes such as Bull Board. Browsers
* prompt for the credentials, so no extra UI is needed.
*/
export function basicAuth(username: string, password: string) {
return (req: Request, res: Response, next: NextFunction) => {
const [scheme, encoded] = (req.headers.authorization ?? '').split(' ');
if (scheme === 'Basic' && encoded) {
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
const separator = decoded.indexOf(':');
const givenUser = decoded.slice(0, separator);
const givenPassword = decoded.slice(separator + 1);
if (
separator !== -1 &&
safeEqual(givenUser, username) &&
safeEqual(givenPassword, password)
) {
next();
return;
}
}
res.set('WWW-Authenticate', 'Basic realm="OpenPanel worker"');
res.status(401).send('Unauthorized');
};
}
3 changes: 2 additions & 1 deletion packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
".": "./index.ts",
"./server": "./server/index.ts",
"./server/get-client-ip": "./server/get-client-ip.ts",
"./server/safe-fetch": "./server/safe-fetch.ts"
"./server/safe-fetch": "./server/safe-fetch.ts",
"./server/share-access": "./server/share-access.ts"
},
"scripts": {
"test": "vitest",
Expand Down
56 changes: 56 additions & 0 deletions packages/common/server/share-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createHmac, timingSafeEqual } from 'node:crypto';

export type ShareType = 'overview' | 'dashboard' | 'report';

export interface ShareAccessInput {
type: ShareType;
/** The public share id (the value in the share link). */
id: string;
/** The stored argon2 hash of the share password. */
passwordHash: string;
}

/**
* Proof that a viewer entered the password of a password-protected share.
*
* The cookie value is an HMAC over the share type, share id and the current
* password hash, so it cannot be forged without COOKIE_SECRET, cannot be
* replayed against a different share, and stops working when the owner
* changes or removes the password. Checking only that the cookie exists is
* what let anyone bypass the password (GHSA-p6c2-mq9r-cx3r).
*/
export function shareAccessCookieName(type: ShareType, id: string): string {
return `shared-${type}-${id}`;
}

function getCookieSecret(): string {
const secret = process.env.COOKIE_SECRET;
if (!secret) {
throw new Error('COOKIE_SECRET environment variable is not set');
}
return secret;
}

export function createShareAccessToken({
type,
id,
passwordHash,
}: ShareAccessInput): string {
return createHmac('sha256', getCookieSecret())
.update(`share-access:${type}:${id}:${passwordHash}`)
.digest('base64url');
}

export function hasShareAccess(
cookies: Record<string, string | undefined> | undefined,
share: ShareAccessInput,
): boolean {
const presented = cookies?.[shareAccessCookieName(share.type, share.id)];
if (!presented) {
return false;
}

const expected = Buffer.from(createShareAccessToken(share));
const actual = Buffer.from(presented);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
36 changes: 34 additions & 2 deletions packages/db/code-migrations/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,38 @@ import {
printBoxMessage,
} from './helpers';

const CREDENTIAL_QUERY_PARAMS = new Set(['password', 'sslpassword', 'user']);

/**
* Connection URLs carry credentials; the startup banner must not put them in
* container and CI logs. Keeps scheme, host, port and database, drops the
* rest. ClickHouse accepts a comma-separated list, so each URL is handled.
*/
function redactConnectionUrls(value: string | undefined): string {
if (!value) {
return '(not set)';
}
return value
.split(',')
.map((raw) => {
try {
const url = new URL(raw.trim());
const params = new URLSearchParams(url.search);
for (const key of [...params.keys()]) {
if (CREDENTIAL_QUERY_PARAMS.has(key.toLowerCase())) {
params.set(key, '***');
}
}
const query = params.toString();
const auth = url.username ? '***@' : '';
return `${url.protocol}//${auth}${url.host}${url.pathname}${query ? `?${query}` : ''}`;
} catch {
return '(unparseable url)';
}
})
.join(',');
}

async function migrate() {
const args = process.argv.slice(2);
const migration = args.filter((arg) => !arg.startsWith('--'))[0];
Expand Down Expand Up @@ -58,8 +90,8 @@ async function migrate() {
]);

printBoxMessage('🌍 Environment', [
`POSTGRES: ${process.env.DATABASE_URL}`,
`CLICKHOUSE: ${process.env.CLICKHOUSE_URL}`,
`POSTGRES: ${redactConnectionUrls(process.env.DATABASE_URL)}`,
`CLICKHOUSE: ${redactConnectionUrls(process.env.CLICKHOUSE_URL)}`,
]);

if (!getIsSelfHosting()) {
Expand Down
23 changes: 20 additions & 3 deletions packages/db/src/services/cohort.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,17 +303,34 @@ export function buildEventCriteriaQuery(
`;
}

/** Columns of the profiles table a cohort filter may reference directly. */
const PROFILE_FILTER_COLUMNS = new Set([
'id',
'first_name',
'last_name',
'email',
'avatar',
'created_at',
'last_seen_at',
]);

// SQL for a profile filter's column: either a properties Map lookup or a
// plain column, qualified with the table name.
function profileColumnAccess(name: string): string {
// plain column, qualified with the table name. The column name is an
// identifier and cannot be escaped like a value, so it must come from the
// allowlist (GHSA-gvwr-5684-wjqc).
export function profileColumnAccess(name: string): string {
const normalizedName = name.replace(/^profile\./, 'profiles.');
if (normalizedName.startsWith('profiles.properties.')) {
const propKey = normalizedName.replace('profiles.properties.', '');
// Escaped: cohort definitions come from the API, so the key is
// user-controlled — a quote in it must not terminate the literal.
return `profiles.properties[${sqlstring.escape(propKey)}]`;
}
return normalizedName;
const column = normalizedName.replace(/^profiles\./, '');
if (!PROFILE_FILTER_COLUMNS.has(column)) {
throw new Error(`Unknown profile filter column: ${name}`);
}
return `profiles.${column}`;
}

function buildProfileCohortHavingClause(
Expand Down
16 changes: 9 additions & 7 deletions packages/db/src/services/conversion.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,14 @@ export class ConversionService {
const funnelWindowSeconds = funnelWindow * 3600;

// Build funnel conditions
const eventAName = sqlstring.escape(eventA.name);
const eventBName = sqlstring.escape(eventB.name);
const conditionA = whereA
? `(events.name = '${eventA.name}' AND ${whereA})`
: `events.name = '${eventA.name}'`;
? `(events.name = ${eventAName} AND ${whereA})`
: `events.name = ${eventAName}`;
const conditionB = whereB
? `(events.name = '${eventB.name}' AND ${whereB})`
: `events.name = '${eventB.name}'`;
? `(events.name = ${eventBName} AND ${whereB})`
: `events.name = ${eventBName}`;

const groupJoin = needsGroupArrayJoin
? `ARRAY JOIN groups AS _group_id LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`
Expand Down Expand Up @@ -175,9 +177,9 @@ export class ConversionService {
${profileJoin}
${groupJoin}
${cohortJoinsSql}
WHERE project_id = '${projectId}'
AND events.name IN ('${eventA.name}', '${eventB.name}')
AND created_at BETWEEN toDateTime('${startDate}') AND toDateTime('${endDate}')
WHERE project_id = ${sqlstring.escape(projectId)}
AND events.name IN (${eventAName}, ${eventBName})
AND created_at BETWEEN toDateTime(${sqlstring.escape(startDate)}) AND toDateTime(${sqlstring.escape(endDate)})
GROUP BY ${group}${breakdownExpressions.length ? `, ${breakdownExpressions.join(', ')}` : ''})
`),
)
Expand Down
Loading
Loading