Skip to content

Commit 8360500

Browse files
oxxoclaude
andcommitted
fix: real license validation — call Polar API directly, fail-closed
Fixes 4 audit findings: 1. Remove soft validation (was trivially bypassable with any ps_pro_ prefix) 2. Use Polar customer-portal endpoint (public, no server token needed) 3. Fail-closed on network error (was fail-open, allowing deliberate bypass) 4. Remove --api-key CLI flag (keys via env var only, prevents shell history exposure) 5. Exit 1 when --simulate requested but license validation fails Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 476c9cd commit 8360500

2 files changed

Lines changed: 24 additions & 27 deletions

File tree

apps/cli/src/index.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ program
3636
.option('--record [dir]', 'Record simulator walkthrough video (requires --simulate, saves .webm)')
3737
.option('--upload', 'Upload HTML report to GitHub Gist and return shareable URL (requires GITHUB_TOKEN)')
3838
.option('--vision', 'Run AI vision analysis on each screen with GPT-4o (requires PROTOSCAN_API_KEY or OPENAI_API_KEY)')
39-
.option('--api-key <key>', 'ProtoScan API key for server-side vision (or set PROTOSCAN_API_KEY env var)')
39+
// PROTOSCAN_API_KEY via env var only — never pass keys as CLI args (shell history exposure)
4040
.option('--max-vision-cost <usd>', 'Maximum USD to spend on vision analysis', '5')
4141
.action(async (input: string, options) => {
4242
// Parse Figma URL or raw file key
@@ -84,7 +84,7 @@ program
8484

8585
let simulatorIssues: Issue[] = [];
8686
if (options.simulate) {
87-
const proKey = options.apiKey ?? process.env.PROTOSCAN_API_KEY;
87+
const proKey = process.env.PROTOSCAN_API_KEY;
8888
if (!proKey) {
8989
console.error('');
9090
console.error(' ⚡ --simulate is a ProtoScan Pro feature.');
@@ -93,12 +93,15 @@ program
9393
console.error('');
9494
console.error(' Static analysis will continue without simulation.');
9595
console.error('');
96-
} else if (!(await validateLicenseKey(proKey)).valid) {
97-
console.error('');
98-
console.error(' ⚠ Invalid or expired PROTOSCAN_API_KEY.');
99-
console.error(' Renew at: https://protoscan.dev/pro');
100-
console.error('');
10196
} else {
97+
const license = await validateLicenseKey(proKey);
98+
if (!license.valid) {
99+
console.error('');
100+
console.error(` ⚠ ${license.error ?? 'Invalid or expired PROTOSCAN_API_KEY.'}`);
101+
console.error(' Get or renew your key at: https://protoscan.dev/pro');
102+
console.error('');
103+
process.exit(1);
104+
}
102105
try {
103106
const recordDir = options.record === true ? '.' : (options.record || undefined);
104107
if (recordDir) {
@@ -127,7 +130,7 @@ program
127130

128131
let visionIssues: Issue[] = [];
129132
if (options.vision) {
130-
const protoscanKey = options.apiKey ?? process.env.PROTOSCAN_API_KEY;
133+
const protoscanKey = process.env.PROTOSCAN_API_KEY;
131134
const openaiKey = process.env.OPENAI_API_KEY;
132135
const screenCount = graph.nodes.size;
133136
const COST_PER_SCREEN = 0.005;

packages/core/src/license.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,29 @@
11
const POLAR_ORG_ID = '1a0b8ee1-1f06-44ea-b6f4-99d7687be563';
2-
const VALIDATE_URL = 'https://api.polar.sh/v1/license-keys/validate';
2+
const VALIDATE_URL = 'https://api.polar.sh/v1/customer-portal/license-keys/validate';
33

44
export interface LicenseValidation {
55
valid: boolean;
66
error?: string;
77
}
88

99
/**
10-
* Validate a ProtoScan API key (Polar.sh license key) against the Polar API.
11-
* Requires POLAR_ACCESS_TOKEN env var for server-side validation.
12-
* Returns { valid: true } if key is active, { valid: false, error } otherwise.
10+
* Validate a ProtoScan API key (Polar.sh license key) against the public
11+
* customer-portal endpoint. No server-side token needed — the endpoint
12+
* accepts organization_id + key directly.
13+
*
14+
* Fail-closed: network errors return invalid (user must be online to validate).
1315
*/
1416
export async function validateLicenseKey(key: string): Promise<LicenseValidation> {
15-
const polarToken = process.env.POLAR_ACCESS_TOKEN;
16-
if (!polarToken) {
17-
// No server token — accept any key with the right prefix as a soft check.
18-
// Full validation happens when POLAR_ACCESS_TOKEN is configured (production).
19-
if (key.startsWith('ps_pro_') && key.length > 10) {
20-
return { valid: true };
21-
}
22-
return { valid: false, error: 'Invalid key format. Keys start with ps_pro_' };
17+
if (!key || key.length < 5) {
18+
return { valid: false, error: 'Invalid key format.' };
2319
}
2420

2521
try {
2622
const res = await fetch(VALIDATE_URL, {
2723
method: 'POST',
28-
headers: {
29-
'Authorization': `Bearer ${polarToken}`,
30-
'Content-Type': 'application/json',
31-
},
24+
headers: { 'Content-Type': 'application/json' },
3225
body: JSON.stringify({ key, organization_id: POLAR_ORG_ID }),
26+
signal: AbortSignal.timeout(10_000),
3327
});
3428

3529
if (res.ok) {
@@ -41,8 +35,8 @@ export async function validateLicenseKey(key: string): Promise<LicenseValidation
4135
}
4236

4337
return { valid: false, error: `Validation failed (HTTP ${res.status})` };
44-
} catch {
45-
// Network error — fail open to avoid blocking users when Polar is down
46-
return { valid: true };
38+
} catch (err) {
39+
const msg = err instanceof Error ? err.message : String(err);
40+
return { valid: false, error: `Unable to verify license — check your network connection. (${msg})` };
4741
}
4842
}

0 commit comments

Comments
 (0)