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
466 changes: 466 additions & 0 deletions SECURITY_REVIEW.md

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions apps/geo-ip-api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,21 @@ import { ENV, getLogger, httpLogger, logger, requestContextMiddleware } from '@j
import { GeoIpLookupResult } from '@jetstream/types';
import { json, urlencoded } from 'body-parser';
import express from 'express';
import { createHash, timingSafeEqual } from 'node:crypto';
import { z, ZodError } from 'zod';
import { downloadMaxMindDb, initMaxMind, lookupIpAddress, validateIpAddress } from './maxmind.service';
import { createRoute } from './route.utils';

/**
* Constant-time credential comparison. Both values are hashed to a fixed-length digest first so the
* comparison does not leak length and does not throw on mismatched lengths.
*/
function timingSafeStringCompare(providedValue: string, expectedValue: string): boolean {
const providedHash = createHash('sha256').update(String(providedValue)).digest();
Comment thread
paustint marked this conversation as resolved.
Dismissed
const expectedHash = createHash('sha256').update(String(expectedValue)).digest();
Comment thread
paustint marked this conversation as resolved.
Dismissed
return timingSafeEqual(providedHash, expectedHash);
}

const DISK_PATH = process.env.DISK_PATH ?? __dirname;

if (ENV.ENVIRONMENT !== 'development' && (!ENV.GEO_IP_API_USERNAME || !ENV.GEO_IP_API_PASSWORD)) {
Expand Down Expand Up @@ -39,7 +50,11 @@ app.use((req, res, next) => {
throw new Error('Unauthorized');
}
const [username, password] = Buffer.from(token, 'base64').toString().split(':');
if (username !== ENV.GEO_IP_API_USERNAME || password !== ENV.GEO_IP_API_PASSWORD) {
// Evaluate both comparisons (no short-circuit) with a constant-time compare so response time
// does not reveal whether the username alone was correct.
const usernameMatches = timingSafeStringCompare(username ?? '', ENV.GEO_IP_API_USERNAME ?? '');
const passwordMatches = timingSafeStringCompare(password ?? '', ENV.GEO_IP_API_PASSWORD ?? '');
if (!usernameMatches || !passwordMatches) {
Comment on lines +55 to +57
throw new Error('Unauthorized');
}
next();
Expand Down Expand Up @@ -101,7 +116,7 @@ app.get(
*/
app.post(
'/api/lookup',
createRoute({ body: z.object({ ips: z.string().array() }) }, async ({ body }, _, res, next) => {
createRoute({ body: z.object({ ips: z.string().array().max(1000) }) }, async ({ body }, _, res, next) => {
try {
const ipAddresses = body.ips;
await initMaxMind(DISK_PATH);
Expand Down
36 changes: 30 additions & 6 deletions apps/jetstream-desktop/src/browser/browser.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { HTTP } from '@jetstream/shared/constants';
import { app, BrowserWindow, clipboard, Menu, nativeImage, shell } from 'electron';
import { app, BrowserWindow, clipboard, Menu, nativeImage } from 'electron';
import log from 'electron-log/main';
import * as path from 'path';
import { ENV } from '../config/environment';
import { initApiConnection } from '../utils/route.utils';
import { openExternalSafe } from '../utils/url.utils';
import { isMac } from '../utils/utils';
import { getWindowConfig } from './config';

Expand Down Expand Up @@ -48,16 +49,19 @@ export class Browser {
if (connection) {
connection.jetstreamConn.org.identity().then(() => {
const sfdcUrl = connection.jetstreamConn.org.getFrontdoorLoginUrl(returnUrl);
shell.openExternal(sfdcUrl);
openExternalSafe(sfdcUrl);
});
return { action: 'deny' };
}
}

// Open all external links in the browser
// FIXME: this should be more protective over what is externally opened
if (url.host !== clientUrl.host) {
shell.openExternal(details.url);
// Open all external links in the browser (only safe schemes are actually opened).
// Use a full origin check (protocol + host) like will-navigate below: with an
// app://jetstream/... CLIENT_URL the host alone is just "jetstream", so a matching-host
// link on another scheme must not be treated as internal.
const isSameAppOrigin = url.protocol === clientUrl.protocol && url.host === clientUrl.host;
if (!isSameAppOrigin) {
openExternalSafe(details.url);
return { action: 'deny' };
}

Expand All @@ -68,6 +72,26 @@ export class Browser {
};
});

// Prevent the renderer from navigating the privileged window away from the app.
// Anything cross-origin is blocked and, if it uses a safe scheme, handed to the default browser
// instead. In-app (SPA) routing uses the history API and does not trigger these events.
const handleWindowNavigation = (event: Electron.Event, targetUrl: string) => {
let parsedUrl: URL;
try {
parsedUrl = new URL(targetUrl);
} catch {
event.preventDefault();
return;
}
const isSameAppOrigin = parsedUrl.protocol === clientUrl.protocol && parsedUrl.host === clientUrl.host;
if (!isSameAppOrigin) {
event.preventDefault();
openExternalSafe(targetUrl);
}
};
browserWindow.webContents.on('will-navigate', handleWindowNavigation);
browserWindow.webContents.on('will-redirect', handleWindowNavigation);

this.createDock();

browserWindow.once('ready-to-show', () => {
Expand Down
6 changes: 3 additions & 3 deletions apps/jetstream-desktop/src/services/file-download.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Maybe } from '@jetstream/types';
import { app, dialog, WebContents } from 'electron';
import logger from 'electron-log';
import { createWriteStream, existsSync } from 'node:fs';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { setRecentDocument } from '../utils/utils';
Expand Down Expand Up @@ -33,7 +33,7 @@ export async function downloadAndZipFilesToDisk(
let downloadPath: string;

if (downloadPreferences?.omitPrompt && downloadPreferences?.downloadPath && existsSync(downloadPreferences.downloadPath)) {
downloadPath = join(downloadPreferences.downloadPath, zipFileName);
downloadPath = join(downloadPreferences.downloadPath, basename(zipFileName));
} else {
const defaultPath = app.getPath('downloads');
const result = await dialog.showSaveDialog({
Expand Down Expand Up @@ -125,7 +125,7 @@ export async function downloadBulkApiFileAndSaveToDisk({
let downloadPath: string;

if (downloadPreferences?.omitPrompt && downloadPreferences?.downloadPath && existsSync(downloadPreferences.downloadPath)) {
downloadPath = join(downloadPreferences.downloadPath, fileName);
downloadPath = join(downloadPreferences.downloadPath, basename(fileName));
} else {
const defaultPath = app.getPath('downloads');
const result = await dialog.showSaveDialog({
Expand Down
7 changes: 4 additions & 3 deletions apps/jetstream-desktop/src/services/notification.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Maybe } from '@jetstream/types';
import { BrowserWindow, dialog, Notification, shell } from 'electron';
import { BrowserWindow, dialog, Notification } from 'electron';
import logger from 'electron-log';
import { openExternalSafe } from '../utils/url.utils';
import { checkNotifications } from './api.service';
import * as dataService from './persistence.service';

Expand Down Expand Up @@ -40,7 +41,7 @@ export function showCriticalNotification(title: string, message: string, actionU

notification.on('click', () => {
if (actionUrl) {
shell.openExternal(actionUrl);
openExternalSafe(actionUrl);
}
});

Expand All @@ -61,6 +62,6 @@ export function showCriticalConfirmationDialog(title: string, message: string, a
});

if (response === 0 && actionUrl) {
shell.openExternal(actionUrl);
openExternalSafe(actionUrl);
}
}
2 changes: 1 addition & 1 deletion apps/jetstream-desktop/src/services/protocol.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function registerDownloadHandler() {
) {
return;
}
const downloadFilename = join(downloadPreferences.downloadPath, item.getFilename());
const downloadFilename = join(downloadPreferences.downloadPath, path.basename(item.getFilename()));
item.setSavePath(downloadFilename);
item.once('done', (_, state) => {
if (state === 'completed') {
Expand Down
31 changes: 31 additions & 0 deletions apps/jetstream-desktop/src/utils/url.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { shell } from 'electron';
import log from 'electron-log/main';

/**
* Schemes that are safe to hand to the OS via shell.openExternal.
* Everything else (file:, smb:/UNC, and custom-protocol handlers) can be coerced by the OS into
* launching a local file or another application, so those are blocked.
*/
const EXTERNAL_URL_ALLOWED_PROTOCOLS = new Set(['https:', 'http:', 'mailto:']);

/**
* Open a URL in the user's default browser, but only if it uses a safe scheme.
* Use this everywhere instead of calling shell.openExternal directly so a renderer-supplied or
* server-supplied URL cannot trigger a dangerous scheme.
*/
export function openExternalSafe(rawUrl: string): void {
try {
const { protocol } = new URL(rawUrl);
if (EXTERNAL_URL_ALLOWED_PROTOCOLS.has(protocol)) {
// Fire-and-forget, but swallow rejections here so a failed OS open cannot become an
// unhandled promise rejection in the main process and callers don't need to handle it.
void shell.openExternal(rawUrl).catch((error) => {
log.warn(`Failed to open external URL: ${error instanceof Error ? error.message : String(error)}`);
});
} else {
log.warn(`Blocked shell.openExternal for disallowed scheme: ${protocol}`);
}
} catch {
log.warn('Blocked shell.openExternal for an invalid URL');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const EVENT_MAP = {
} as const;

window.addEventListener('message', async (event) => {
if (event.origin !== targetOrigin) {
// Only accept messages posted by this same page (not an embedding/opener frame) and from our origin
if (event.source !== window || event.origin !== targetOrigin) {
return;
}
if (event.data?.message === EVENT_MAP.ACKNOWLEDGE) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { initApiClientAndOrg } from '../utils/api-client';
import {
AUTH_CHECK_INTERVAL_MIN,
AuthTokensStorage,
eventPayload,
ExternalIdentifier,
ExtIdentifierStorage,
GetPageUrl,
Expand Down Expand Up @@ -154,51 +153,15 @@ browser.commands.onCommand.addListener(async (command, tab) => {
});

/**
* Handle authentication events from Jetstream server
* User is redirected and authenticated on the Jetstream server
* and tokens are sent back to the extension and stored in chrome storage
* NOTE: There is intentionally no `onMessageExternal` listener.
*
* By default `onMessageExternal` is reachable by any other installed extension (and the extension
* id is public), which previously allowed a co-installed extension to read this device's identifier
* and inject an attacker-owned, device-bound auth token (session fixation). The legitimate login
* flow runs entirely through the origin-validated `contentAuthScript` content script (injected only
* on getjetstream.app/web-extension/*) and the internal `onMessage` handler below, so no external
* messaging surface is required.
*/
browser.runtime.onMessageExternal.addListener(async (message: unknown) => {
try {
logger.log('Received message from external extension', message);
const event = eventPayload.parse(message);
switch (event.type) {
case 'EXT_IDENTIFIER': {
let result = (await browser.storage.sync.get(storageTypes.extIdentifier.key)) as Partial<ExtIdentifierStorage>;
if (!result.extIdentifier) {
result = { extIdentifier: { id: crypto.randomUUID() } };
await browser.storage.sync.set(result);
storageSyncCache.extIdentifier = result.extIdentifier;
}
if (!result.extIdentifier?.id) {
throw new Error('Could not get or initialize extension identifier');
}
logger.info('Extension identifier', result.extIdentifier.id);
return { success: true, data: result.extIdentifier.id };
}
case 'TOKENS': {
const { exp, userProfile } = jwtDecode<JwtPayload>(event.data.accessToken);
const expiresAt = exp ? fromUnixTime(exp) : new Date();
const authState = {
accessToken: event.data.accessToken,
userProfile,
expiresAt: expiresAt.getTime(),
lastChecked: null,
loggedIn: true,
};
await browser.storage.sync.set({ [storageTypes.authTokens.key]: authState });
storageSyncCache.authTokens = authState;
return { success: true };
}
default: {
return { success: false, error: 'Unknown message type' };
}
}
} catch (ex) {
logger.error('Error handling message', ex);
return { success: false, error: 'Error handling message' };
}
});

// connections seem to continually get reset
// and we cannot make async calls in the fetch event listener to get from storage
Expand Down
6 changes: 3 additions & 3 deletions libs/auth/server/src/lib/auth.db.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export async function hasRememberDeviceRecord({
if (rememberMe.userAgent && userAgent) {
const isSimilar = checkUserAgentSimilarity(rememberMe.userAgent, userAgent);
if (!isSimilar) {
logger.warn({ deviceId, userId }, `User agent mismatch for remembered device: ${rememberMe.userAgent} !== ${userAgent}`);
logger.warn({ userId }, `User agent mismatch for remembered device: ${rememberMe.userAgent} !== ${userAgent}`);
return false;
}
}
Expand Down Expand Up @@ -834,8 +834,8 @@ export async function revokeExternalSession(userId: string, sessionId: string) {
if (!userId || !sessionId) {
throw new Error('Invalid parameters');
}
await prisma.webExtensionToken.delete({
where: { id: sessionId },
await prisma.webExtensionToken.deleteMany({
where: { id: sessionId, userId },
});
}

Expand Down
33 changes: 26 additions & 7 deletions libs/types/src/lib/sync/sync.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,33 @@ const DateTimeSchema = z.union([z.string(), z.date()]).transform((val) => (val i
*/
const RESERVED_OBJECT_KEYS = ['__proto__', 'constructor', 'prototype'];

/**
* Maximum nesting depth allowed in a sync `data` payload. Legit sync records are shallow; this cap
* prevents a maliciously deep object from exhausting the stack while walking it.
*/
const MAX_SYNC_DATA_DEPTH = 100;

/**
* Recursively strip dangerous own keys (__proto__/constructor/prototype) from an object/array
* at any depth so a nested `__proto__` cannot survive into persisted JSONB or be re-broadcast.
* A single in-place walk that deletes the dangerous own keys; no deep clone is performed.
* Throws if the payload nests deeper than MAX_SYNC_DATA_DEPTH.
*/
const stripReservedKeys = (value: unknown, seen = new WeakSet<object>()): void => {
const stripReservedKeys = (value: unknown, seen = new WeakSet<object>(), depth = 0): void => {
if (value === null || typeof value !== 'object') {
return;
}
if (depth > MAX_SYNC_DATA_DEPTH) {
throw new Error('Sync record data exceeds the maximum allowed nesting depth');
}
Comment thread
paustint marked this conversation as resolved.
if (seen.has(value)) {
return;
}
seen.add(value);

if (Array.isArray(value)) {
for (const item of value) {
stripReservedKeys(item, seen);
stripReservedKeys(item, seen, depth + 1);
}
return;
}
Expand All @@ -41,16 +51,23 @@ const stripReservedKeys = (value: unknown, seen = new WeakSet<object>()): void =
}
}
for (const nestedValue of Object.values(value)) {
stripReservedKeys(nestedValue, seen);
stripReservedKeys(nestedValue, seen, depth + 1);
}
};

const SyncRecordKeySchema = z
.string()
.refine((key) => !RESERVED_OBJECT_KEYS.includes(key), { message: 'Sync record key uses a reserved name' });

const SyncRecordDataSchema = z.record(z.string(), z.unknown()).transform((data) => {
stripReservedKeys(data);
const SyncRecordDataSchema = z.record(z.string(), z.unknown()).transform((data, ctx) => {
try {
stripReservedKeys(data);
} catch {
// Surface the depth-cap breach as a normal Zod validation issue (clean 400) rather than a
// raw thrown Error that would bubble out of the transform as an unhandled 500.
ctx.addIssue('Sync record data exceeds the maximum allowed nesting depth');
return z.NEVER;
}
return data;
});

Expand All @@ -67,14 +84,16 @@ export type EntitySyncStatus = z.infer<typeof EntitySyncStatusSchema>;

export const SyncRecordOperationBaseSchema = z.object({
key: SyncRecordKeySchema,
hashedKey: z.string(),
// hashedKey is a fixed-length hash; the DB column is char(40). Cap length so an over-long value is
// rejected with a clean validation error instead of aborting the insert transaction.
hashedKey: z.string().max(40),
Comment thread
paustint marked this conversation as resolved.
entity: SyncTypeSchema,
data: SyncRecordDataSchema,
});

export const SyncRecordOperationCreateUpdateSchema = SyncRecordOperationBaseSchema.extend({
type: z.enum(['create', 'update']),
orgId: z.string().nullish(),
orgId: z.string().max(255).nullish(),
createdAt: DateTimeSchema,
updatedAt: DateTimeSchema,
});
Expand Down
Loading