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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- [EE] Verified signed online license assertions before granting paid feature entitlements. [#1442](https://github.com/sourcebot-dev/sourcebot/pull/1442)

## [5.1.0] - 2026-07-10

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "License" ADD COLUMN "licenseAssertion" TEXT;
3 changes: 3 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,9 @@ model License {
lastSyncAt DateTime?
lastSyncErrorCode String?
/// Compact, signed online-license assertion returned by Lighthouse.
/// Authorization must be derived from this assertion when it is present.
licenseAssertion String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Expand Down
76 changes: 76 additions & 0 deletions packages/shared/src/entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
env: {
SOURCEBOT_PUBLIC_KEY_PATH: '/tmp/test-key',
SOURCEBOT_EE_LICENSE_KEY: undefined as string | undefined,
SOURCEBOT_INSTALL_ID: 'test-install',
} as Record<string, string | undefined>,
verifySignature: vi.fn(() => true),
}));
Expand Down Expand Up @@ -78,11 +79,29 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
yearlyPeakSeats: null,
lastSyncAt: new Date(),
lastSyncErrorCode: null,
licenseAssertion: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
});

const onlineAssertion = (overrides: Record<string, unknown> = {}): string => {
const payload = {
version: 1,
audience: 'sourcebot-online-license',
licenseId: 'subscription-1',
installId: 'test-install',
status: 'active',
entitlements: ['sso'],
seats: 10,
issuedAt: new Date(Date.now() - 60 * 1000).toISOString(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
...overrides,
};

return `${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fake-signature`;
};

beforeEach(() => {
mocks.env.SOURCEBOT_EE_LICENSE_KEY = undefined;
mocks.verifySignature.mockReturnValue(true);
Expand Down Expand Up @@ -178,6 +197,63 @@ describe('getEntitlements', () => {
expect(getEntitlements(makeLicense({ entitlements: ['sso'] }))).toEqual([]);
});

describe('signed online assertions', () => {
test('uses entitlements from a valid assertion instead of mutable columns', () => {
const license = makeLicense({
status: 'active',
entitlements: ['audit'],
licenseAssertion: onlineAssertion({ entitlements: ['sso'] }),
});

expect(getEntitlements(license)).toEqual(['sso']);
});

test('does not fall back to mutable columns when the signature is invalid', () => {
mocks.verifySignature.mockReturnValue(false);
const license = makeLicense({
status: 'active',
entitlements: ['audit'],
licenseAssertion: onlineAssertion(),
});

expect(getEntitlements(license)).toEqual([]);
});

test('rejects an assertion issued for another installation', () => {
const license = makeLicense({
status: 'active',
entitlements: ['audit'],
licenseAssertion: onlineAssertion({ installId: 'different-install' }),
});

expect(getEntitlements(license)).toEqual([]);
});

test('rejects an expired assertion even when lastSyncAt was forged', () => {
const license = makeLicense({
status: 'active',
entitlements: ['audit'],
lastSyncAt: new Date(),
licenseAssertion: onlineAssertion({
issuedAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
}),
});

expect(getEntitlements(license)).toEqual([]);
});

test('rejects active mutable columns when the signed status is canceled', () => {
const license = makeLicense({
status: 'active',
entitlements: ['audit'],
licenseAssertion: onlineAssertion({ status: 'canceled' }),
});

expect(getEntitlements(license)).toEqual([]);
});
});

test('returns all entitlements when offline key is valid', () => {
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 });
const result = getEntitlements(null);
Expand Down
109 changes: 106 additions & 3 deletions packages/shared/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [
'past_due',
];

const ONLINE_LICENSE_ASSERTION_AUDIENCE = 'sourcebot-online-license';
const ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS = 5 * 60 * 1000;

// Compatibility switch for the first release that understands signed online
// licenses. Set this to false in the enforcement release, after Lighthouse has
// been returning assertions for at least one full online-license TTL.
const ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES = true;

// @WARNING: when adding a new entitlement to this list, make sure
// lighthouse/lambda/entitlements.ts is also updated && deployed
// prior to rolling a new Sourcebot version.
Expand All @@ -47,6 +55,74 @@ const ALL_ENTITLEMENTS = [
] as const;
export type Entitlement = (typeof ALL_ENTITLEMENTS)[number];

const onlineLicenseAssertionPayloadSchema = z.object({
version: z.literal(1),
audience: z.literal(ONLINE_LICENSE_ASSERTION_AUDIENCE),
licenseId: z.string().min(1),
installId: z.string().min(1),
Comment on lines +61 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the licenseId contract and the synchronization mapping without executing repository code.
rg -n -C4 '\blicenseId\b' packages
rg -n -C4 '\blicenseAssertion\b' packages/web packages/shared packages/db

Repository: sourcebot-dev/sourcebot

Length of output: 11713


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,260p' packages/shared/src/entitlements.ts
printf '\n---\n'
sed -n '1,280p' packages/web/src/features/billing/servicePing.ts
printf '\n---\n'
sed -n '340,420p' packages/db/prisma/schema.prisma

Repository: sourcebot-dev/sourcebot

Length of output: 19338


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C8 'function verifyOnlineLicenseAssertion|const verifyOnlineLicenseAssertion|verifyOnlineLicenseAssertion\(' packages/shared/src/entitlements.ts packages/web/src/features/billing/servicePing.ts packages/shared/src/entitlements.test.ts

Repository: sourcebot-dev/sourcebot

Length of output: 4348


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 '\blicenseId\b' packages/db packages/web packages/shared | sed -n '1,240p'

Repository: sourcebot-dev/sourcebot

Length of output: 1211


Bind online assertions to a persisted license ID.
installId alone still lets a valid assertion be reused across different License rows on the same installation. Persist the upstream licenseId, compare it before accepting the assertion, and add a cross-license replay test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/entitlements.ts` around lines 61 - 62, Update the online
assertion persistence and validation flow around the entitlement schema fields
licenseId and installId to retain the upstream licenseId with each persisted
assertion, and require it to match the current License row before accepting the
assertion. Add a test covering reuse of a valid assertion across different
License rows on the same installation, ensuring the cross-license replay is
rejected.

status: z.enum([
'active',
'trialing',
'past_due',
'unpaid',
'canceled',
'incomplete',
'incomplete_expired',
'paused',
]),
entitlements: z.array(z.enum(ALL_ENTITLEMENTS)),
seats: z.number().int().nonnegative(),
issuedAt: z.string().datetime(),
expiresAt: z.string().datetime(),
}).strict();

export type OnlineLicenseAssertionPayload = z.infer<typeof onlineLicenseAssertionPayloadSchema>;

/**
* Verifies and decodes an online-license assertion. The signature covers the
* encoded payload itself, avoiding cross-language JSON canonicalization.
*/
export const verifyOnlineLicenseAssertion = (assertion: string): OnlineLicenseAssertionPayload | null => {
try {
const parts = assertion.split('.');
if (parts.length !== 2) {
return null;
}

const [encodedPayload, signature] = parts;
if (!encodedPayload || !signature) {
return null;
}

if (!verifySignature(encodedPayload, signature, env.SOURCEBOT_PUBLIC_KEY_PATH)) {
logger.error('Online license assertion signature verification failed');
return null;
}

const decodedPayload = Buffer.from(encodedPayload, 'base64url').toString('utf8');
const payload = onlineLicenseAssertionPayloadSchema.parse(JSON.parse(decodedPayload));
const issuedAt = new Date(payload.issuedAt).getTime();
const expiresAt = new Date(payload.expiresAt).getTime();
const now = Date.now();

if (
payload.installId !== env.SOURCEBOT_INSTALL_ID ||
issuedAt > now + ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS ||
expiresAt <= now ||
expiresAt <= issuedAt ||
(expiresAt - issuedAt) > STALE_ONLINE_LICENSE_THRESHOLD_MS
) {
logger.error('Online license assertion claims are invalid');
return null;
}

return payload;
} catch (error) {
logger.error(`Failed to verify online license assertion: ${error}`);
return null;
}
};

const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense | null => {
try {
const decodedPayload = base64Decode(payload);
Expand Down Expand Up @@ -114,7 +190,9 @@ export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
// so the warning has a chance to fire before entitlements are stripped.
export const STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000;

const getValidOnlineLicense = (_license: License | null): License | null => {
type ValidOnlineLicense = Pick<OnlineLicenseAssertionPayload, 'entitlements' | 'status'>;

const getValidLegacyOnlineLicense = (_license: License | null): ValidOnlineLicense | null => {
if (
_license &&
_license.status &&
Expand All @@ -123,7 +201,32 @@ const getValidOnlineLicense = (_license: License | null): License | null => {
(Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS &&
_license.lastSyncErrorCode !== 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE'
) {
return _license;
return {
entitlements: _license.entitlements as Entitlement[],
status: _license.status as LicenseStatus,
};
}

return null;
}

const getValidOnlineLicense = (_license: License | null): ValidOnlineLicense | null => {
// A present but invalid assertion must never fall back to unsigned columns.
if (_license?.licenseAssertion !== null && _license?.licenseAssertion !== undefined) {
if (_license.lastSyncErrorCode === 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE') {
return null;
}

const assertion = verifyOnlineLicenseAssertion(_license.licenseAssertion);
if (assertion && ACTIVE_ONLINE_LICENSE_STATUSES.includes(assertion.status)) {
return assertion;
}

return null;
}

if (ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES) {
return getValidLegacyOnlineLicense(_license);
}

return null;
Expand Down Expand Up @@ -208,4 +311,4 @@ export const getSeatCap = (): number | undefined => {
}

return undefined;
}
}
2 changes: 2 additions & 0 deletions packages/shared/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ export {
getOfflineLicenseMetadata,
STALE_ONLINE_LICENSE_THRESHOLD_MS,
STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS,
verifyOnlineLicenseAssertion,
} from "./entitlements.js";
export type {
Entitlement,
OfflineLicenseMetadata,
OnlineLicenseAssertionPayload,
} from "./entitlements.js";
export type {
RepoMetadata,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
yearlyPeakSeats: null,
lastSyncAt: NOW,
lastSyncErrorCode: null,
licenseAssertion: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
Expand Down
11 changes: 10 additions & 1 deletion packages/web/src/features/billing/servicePing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
decryptActivationCode,
env,
SOURCEBOT_VERSION,
isValidOfflineLicenseActive
isValidOfflineLicenseActive,
verifyOnlineLicenseAssertion,
} from "@sourcebot/shared";
import { client } from "./client";
import { ServicePingRequest } from "./types";
Expand Down Expand Up @@ -127,6 +128,13 @@ export const syncWithLighthouse = async (orgId: number) => {

// If we have a license and Lighthouse returned license data, sync it
if (license && response.license) {
if (response.licenseAssertion && !verifyOnlineLicenseAssertion(response.licenseAssertion)) {
// Never persist an assertion we cannot authenticate. In particular,
// do not silently write only the legacy fields and create a
// signature-downgrade path.
throw new Error('Lighthouse returned an invalid online license assertion');
}
Comment on lines 129 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Handle assertion presence independently and fail closed.

response.licenseAssertion must be checked with an explicit presence test, not truthiness, and validation must occur before the response.license block. As written, an empty assertion or an assertion returned without response.license is ignored; the latter also prevents persistence and can leave stale entitlements active. Persist assertion-only responses if supported, or reject them as malformed protocol responses.

Proposed fix
+    const hasLicenseAssertion = response.licenseAssertion !== undefined;
+    if (hasLicenseAssertion && !response.license) {
+        throw new Error('Lighthouse returned an assertion without license data');
+    }
+    if (hasLicenseAssertion && !verifyOnlineLicenseAssertion(response.licenseAssertion)) {
+        throw new Error('Lighthouse returned an invalid online license assertion');
+    }
+
     // If we have a license and Lighthouse returned license data, sync it
     if (license && response.license) {
-        if (response.licenseAssertion && !verifyOnlineLicenseAssertion(response.licenseAssertion)) {
-            throw new Error('Lighthouse returned an invalid online license assertion');
-        }
-
         // ...
-        ...(response.licenseAssertion && { licenseAssertion: response.licenseAssertion }),
+        ...(hasLicenseAssertion && { licenseAssertion: response.licenseAssertion }),
     }

Also applies to: 185-185

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/features/billing/servicePing.ts` around lines 129 - 136,
Update the service-ping handling around verifyOnlineLicenseAssertion so
assertion presence is checked explicitly and validated before the
response.license synchronization block, rejecting empty or invalid assertions.
Handle assertion-only responses independently: persist them when supported, or
reject the response as malformed rather than skipping persistence and leaving
stale entitlements active; apply the same behavior to the corresponding later
handling.


const {
entitlements,
seats,
Expand Down Expand Up @@ -174,6 +182,7 @@ export const syncWithLighthouse = async (orgId: number) => {
yearlyPeakSeats: yearlyTermStatus?.peakSeats ?? null,
lastSyncAt: new Date(),
lastSyncErrorCode: null,
...(response.licenseAssertion && { licenseAssertion: response.licenseAssertion }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale assertion survives license sync

High Severity

The license sync updates mutable fields but retains the existing licenseAssertion if Lighthouse doesn't provide a new one. This allows stale, signed entitlements to persist, granting continued access even after a subscription is canceled or downgraded.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fe50da2. Configure here.

},
});

Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/features/billing/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export const servicePingResponseSchema = z.object({
hasPaymentMethod: z.boolean(),
yearlyTermStatus: yearlyTermStatusSchema.optional(),
}).optional(),
// Optional while older Lighthouse deployments are being upgraded.
licenseAssertion: z.string().optional(),
});
export type ServicePingResponse = z.infer<typeof servicePingResponseSchema>;

Expand Down