From 6c43c7a4de32319d28a31bc70d8f35a0178219ec Mon Sep 17 00:00:00 2001 From: DEVARAJ K Date: Sat, 29 Aug 2026 17:41:55 +0000 Subject: [PATCH] fix(cursor): pass cursor.com's CSRF origin check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First live test on a real account: cookies attach fine, but cursor.com rejects the extension's POSTs with 403 'Invalid origin for state-changing request' because extension requests carry a chrome-extension:// Origin. (Claude and Codex use GETs, which is why only Cursor hit this.) Adapters can now declare originOverride; a declarativeNetRequest session rule sets the Origin header to the provider's own origin on requests matching its API prefix — scoped via initiatorDomains to requests initiated by THIS extension only, so no other page's traffic is touched and the provider's CSRF protection stays intact for everyone else. Rules re-sync on startup/install (session rules don't survive restarts) and on every provider toggle. Requires declarativeNetRequestWithHostAccess (no warning prompt; acts only on user-granted hosts); justification added to the store listing and privacy policy. Version 0.2.1. --- docs/store/listing.md | 12 +++++++ package.json | 2 +- public/manifest.json | 4 +-- site/privacy.html | 3 +- src/adapters/cursor.ts | 4 +++ src/background/index.ts | 6 ++++ src/background/request-rules.ts | 49 +++++++++++++++++++++++++++ src/types.ts | 8 +++++ tests/chrome-fake.ts | 18 ++++++++++ tests/request-rules.test.ts | 59 +++++++++++++++++++++++++++++++++ 10 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 src/background/request-rules.ts create mode 100644 tests/request-rules.test.ts diff --git a/docs/store/listing.md b/docs/store/listing.md index fa9cd5e..d2ab935 100644 --- a/docs/store/listing.md +++ b/docs/store/listing.md @@ -96,6 +96,18 @@ browser session. persistent background process. ``` +- `declarativeNetRequestWithHostAccess`: + + ``` + Some providers' usage APIs (Cursor) reject requests whose Origin header + is not the provider's own site. While such a provider is enabled, one + rule sets the Origin header to that provider's origin on this + extension's own read-only usage requests to that provider's API — and + nothing else: the rule is scoped to requests initiated by this + extension, on hosts the user has explicitly granted, so no other page's + requests are affected. No request content is read or modified. + ``` + - Optional host permission `https://claude.ai/*`: ``` diff --git a/package.json b/package.json index 8bc477c..f1824e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ration", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Ration — AI Quota Tracker. A browser extension showing remaining quota across your AI subscriptions in one glance.", "type": "module", diff --git a/public/manifest.json b/public/manifest.json index 19348ff..39f0bb6 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,10 +1,10 @@ { "manifest_version": 3, "name": "Ration — AI Quota Tracker", - "version": "0.2.0", + "version": "0.2.1", "description": "One glance at remaining quota across your AI subscriptions. No accounts, no telemetry, no credential access.", "minimum_chrome_version": "120", - "permissions": ["storage", "alarms"], + "permissions": ["storage", "alarms", "declarativeNetRequestWithHostAccess"], "optional_host_permissions": [ "https://claude.ai/*", "https://chatgpt.com/*", diff --git a/site/privacy.html b/site/privacy.html index 0ac0e63..3b32831 100644 --- a/site/privacy.html +++ b/site/privacy.html @@ -106,7 +106,8 @@

Permissions

Changes and contact

diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index a045135..65c90dc 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -122,6 +122,10 @@ export const cursorAdapter: ProviderAdapter = { hostPermissions: ['https://cursor.com/*'], dashboardUrl: 'https://cursor.com/dashboard', minRefreshMs: 60_000, + // cursor.com rejects POSTs whose Origin isn't its own ("Invalid origin + // for state-changing request"), and extension requests carry a + // chrome-extension:// Origin. See background/request-rules.ts. + originOverride: { origin: 'https://cursor.com', urlPrefix: 'https://cursor.com/api/' }, async fetch(ctx: FetchContext): Promise { const base = { diff --git a/src/background/index.ts b/src/background/index.ts index a132b8f..55101de 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -6,6 +6,7 @@ import type { Msg } from '../types'; import { getSettings, putSettings, removeProviderData, wipeAll } from '../lib/storage'; import { ALARM_NAME, ALARM_PERIOD_MIN, refreshDueProviders, updateBadge } from './refresh'; +import { syncOriginRules } from './request-rules'; function ensureAlarm(): void { // Idempotent: re-creating an alarm with the same name just reschedules it. @@ -15,11 +16,14 @@ function ensureAlarm(): void { chrome.runtime.onInstalled.addListener(() => { ensureAlarm(); void updateBadge(); + void getSettings().then(syncOriginRules); }); chrome.runtime.onStartup.addListener(() => { ensureAlarm(); void updateBadge(); + // Session rules don't survive browser restarts; re-sync from settings. + void getSettings().then(syncOriginRules); }); chrome.alarms.onAlarm.addListener((alarm) => { @@ -35,6 +39,7 @@ async function handleMessage(msg: Msg): Promise { const settings = await getSettings(); settings.providers[msg.providerId] = { enabled: msg.enabled }; await putSettings(settings); + await syncOriginRules(settings); if (msg.enabled) { await refreshDueProviders('enable', msg.providerId); } else { @@ -45,6 +50,7 @@ async function handleMessage(msg: Msg): Promise { } case 'wipeAll': await wipeAll(); + await syncOriginRules(await getSettings()); await updateBadge(); return; } diff --git a/src/background/request-rules.ts b/src/background/request-rules.ts new file mode 100644 index 0000000..6799812 --- /dev/null +++ b/src/background/request-rules.ts @@ -0,0 +1,49 @@ +/** + * Origin-header rules for providers whose API rejects extension-origin + * requests as CSRF (adapter.originOverride). A declarativeNetRequest session + * rule sets the Origin header to the provider's own origin — but ONLY on + * requests initiated by this extension (initiatorDomains scoping), so other + * pages' requests are untouched and the provider's CSRF protection is not + * weakened for anyone else. The user-agent and everything else stay honest; + * this only makes our read-only usage requests pass the same origin check + * the provider's own dashboard passes. + * + * Session rules don't survive a browser restart, so the service worker + * re-syncs them on startup/install and whenever a provider is toggled. + */ +import { adapters } from '../adapters'; +import type { Settings } from '../types'; + +const RULE_ID_BASE = 100; + +export async function syncOriginRules(settings: Settings): Promise { + const dnr = chrome.declarativeNetRequest; + if (!dnr?.updateSessionRules) return; + + const removeRuleIds: number[] = []; + const addRules: chrome.declarativeNetRequest.Rule[] = []; + + adapters.forEach((adapter, index) => { + const ruleId = RULE_ID_BASE + index; + removeRuleIds.push(ruleId); + const override = adapter.originOverride; + if (!override || !settings.providers[adapter.id]?.enabled) return; + addRules.push({ + id: ruleId, + priority: 1, + action: { + type: dnr.RuleActionType.MODIFY_HEADERS, + requestHeaders: [ + { header: 'origin', operation: dnr.HeaderOperation.SET, value: override.origin }, + ], + }, + condition: { + urlFilter: `|${override.urlPrefix}`, + initiatorDomains: [chrome.runtime.id], + resourceTypes: [dnr.ResourceType.XMLHTTPREQUEST], + }, + }); + }); + + await dnr.updateSessionRules({ removeRuleIds, addRules }); +} diff --git a/src/types.ts b/src/types.ts index 8b5e2c0..42d6a06 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,6 +62,14 @@ export interface ProviderAdapter { dashboardUrl: string; /** Per-provider rate-limit floor: never refetch more often than this. */ minRefreshMs: number; + /** + * For providers whose API rejects extension-origin requests as CSRF + * ("Invalid origin for state-changing request"): while the provider is + * enabled, the extension's OWN requests matching `urlPrefix` get their + * Origin header set to `origin` via declarativeNetRequest. Scoped to + * requests initiated by this extension only — never other pages'. + */ + originOverride?: { origin: string; urlPrefix: string }; /** * MUST never throw and MUST never coerce an unparseable response to zero — * every failure path returns a snapshot with an honest error status. diff --git a/tests/chrome-fake.ts b/tests/chrome-fake.ts index 298dcfd..ced40d5 100644 --- a/tests/chrome-fake.ts +++ b/tests/chrome-fake.ts @@ -3,12 +3,14 @@ export interface ChromeFake { store: Map; badge: { text: string; color: string }; + sessionRules: Map; uninstall: () => void; } export function installChromeFake(): ChromeFake { const store = new Map(); const badge = { text: '', color: '' }; + const sessionRules = new Map(); const local = { async get(keys: string | string[] | null): Promise> { @@ -44,12 +46,28 @@ export function installChromeFake(): ChromeFake { alarms: { create(): void {}, }, + runtime: { + id: 'test-extension-id', + }, + declarativeNetRequest: { + RuleActionType: { MODIFY_HEADERS: 'modifyHeaders' }, + HeaderOperation: { SET: 'set' }, + ResourceType: { XMLHTTPREQUEST: 'xmlhttprequest' }, + async updateSessionRules(options: { + removeRuleIds?: number[]; + addRules?: { id: number }[]; + }): Promise { + for (const id of options.removeRuleIds ?? []) sessionRules.delete(id); + for (const rule of options.addRules ?? []) sessionRules.set(rule.id, rule); + }, + }, }; (globalThis as { chrome?: unknown }).chrome = fake; return { store, badge, + sessionRules, uninstall: () => { delete (globalThis as { chrome?: unknown }).chrome; }, diff --git a/tests/request-rules.test.ts b/tests/request-rules.test.ts new file mode 100644 index 0000000..27ec7f4 --- /dev/null +++ b/tests/request-rules.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { adapters } from '../src/adapters'; +import { syncOriginRules } from '../src/background/request-rules'; +import type { Settings } from '../src/types'; +import { installChromeFake, type ChromeFake } from './chrome-fake'; + +const settings = (enabled: string[]): Settings => ({ + providers: Object.fromEntries(enabled.map((id) => [id, { enabled: true }])), +}); + +describe('syncOriginRules', () => { + let fake: ChromeFake; + + beforeEach(() => { + fake = installChromeFake(); + }); + + afterEach(() => { + fake.uninstall(); + }); + + it('adds an origin rule for enabled providers that declare originOverride', async () => { + await syncOriginRules(settings(['cursor'])); + + expect(fake.sessionRules.size).toBe(1); + const rule = [...fake.sessionRules.values()][0] as { + action: { type: string; requestHeaders: { header: string; value: string }[] }; + condition: { urlFilter: string; initiatorDomains: string[]; resourceTypes: string[] }; + }; + expect(rule.action.type).toBe('modifyHeaders'); + expect(rule.action.requestHeaders).toEqual([ + { header: 'origin', operation: 'set', value: 'https://cursor.com' }, + ]); + expect(rule.condition.urlFilter).toBe('|https://cursor.com/api/'); + // Scoped to the extension's own requests only — never other pages'. + expect(rule.condition.initiatorDomains).toEqual(['test-extension-id']); + }); + + it('adds no rules for providers without originOverride', async () => { + await syncOriginRules(settings(['claude', 'codex'])); + expect(fake.sessionRules.size).toBe(0); + }); + + it('removes the rule when the provider is disabled', async () => { + await syncOriginRules(settings(['cursor'])); + expect(fake.sessionRules.size).toBe(1); + await syncOriginRules(settings([])); + expect(fake.sessionRules.size).toBe(0); + }); + + it('is a no-op when declarativeNetRequest is unavailable', async () => { + delete (globalThis.chrome as { declarativeNetRequest?: unknown }).declarativeNetRequest; + await expect(syncOriginRules(settings(['cursor']))).resolves.toBeUndefined(); + }); + + it('only cursor currently declares an originOverride', () => { + expect(adapters.filter((a) => a.originOverride).map((a) => a.id)).toEqual(['cursor']); + }); +});