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
12 changes: 12 additions & 0 deletions docs/store/listing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/*`:

```
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -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/*",
Expand Down
3 changes: 2 additions & 1 deletion site/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ <h2>Permissions</h2>
<ul>
<li><b>storage</b> — to keep your readings and settings on your device.</li>
<li><b>alarms</b> — to refresh quota readings on a schedule (every 5 minutes per enabled provider).</li>
<li><b>Provider site access</b> (e.g. claude.ai, chatgpt.com) — optional, requested only when you switch that provider on, and used solely to read your quota from that provider.</li>
<li><b>Provider site access</b> (e.g. claude.ai, chatgpt.com, cursor.com) — optional, requested only when you switch that provider on, and used solely to read your quota from that provider.</li>
<li><b>declarativeNetRequestWithHostAccess</b> — some providers' APIs (Cursor) reject requests unless the Origin header is the provider's own site; while such a provider is enabled, one rule sets that header on Ration's own read-only usage requests to that provider. The rule is scoped to Ration's own requests on sites you granted — it never touches any other page's traffic.</li>
</ul>

<h2>Changes and contact</h2>
Expand Down
4 changes: 4 additions & 0 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderSnapshot> {
const base = {
Expand Down
6 changes: 6 additions & 0 deletions src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) => {
Expand All @@ -35,6 +39,7 @@ async function handleMessage(msg: Msg): Promise<void> {
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 {
Expand All @@ -45,6 +50,7 @@ async function handleMessage(msg: Msg): Promise<void> {
}
case 'wipeAll':
await wipeAll();
await syncOriginRules(await getSettings());
await updateBadge();
return;
}
Expand Down
49 changes: 49 additions & 0 deletions src/background/request-rules.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 });
}
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions tests/chrome-fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
export interface ChromeFake {
store: Map<string, unknown>;
badge: { text: string; color: string };
sessionRules: Map<number, unknown>;
uninstall: () => void;
}

export function installChromeFake(): ChromeFake {
const store = new Map<string, unknown>();
const badge = { text: '', color: '' };
const sessionRules = new Map<number, unknown>();

const local = {
async get(keys: string | string[] | null): Promise<Record<string, unknown>> {
Expand Down Expand Up @@ -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<void> {
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;
},
Expand Down
59 changes: 59 additions & 0 deletions tests/request-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading