From fc54c3e85619a518ff8f06cf700dd06e2625fcc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 22:08:02 +0000 Subject: [PATCH] [AI] Add lint guardrail blocking secure-context-only Web APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a custom oxlint rule, `actual/no-secure-context-apis`, that errors on direct uses of Web APIs only available in secure contexts (HTTPS or localhost). Actual must keep working when self-hosted over plain HTTP, so introducing new direct uses (e.g. crypto.randomUUID, navigator.clipboard, crypto.subtle) is a regression risk — issue #7715 broke the login screen this way in 26.5.0. The deny list is generated from @mdn/browser-compat-data (entries with a `secure_context_required` subfeature) plus a small manual augment list for APIs that BCD has not yet tagged. A drift-check CI step ensures the checked-in JSON stays in sync with the installed BCD version. Existing call sites are grandfathered via per-file overrides in .oxlintrc.json — the rule prevents *new* call sites without forcing a fix to existing code. Server-side packages (sync-server, api, loot-core server, migrations, etc.) are also exempt since secure-context restrictions don't apply in Node. https://claude.ai/code/session_013X54n6mvVRTJcBeks5faMP --- .github/workflows/check.yml | 2 + .oxlintrc.json | 54 +++++ AGENTS.md | 1 + .../lib/data/generate-secure-context-apis.mjs | 188 ++++++++++++++++++ .../lib/data/secure-context-apis.json | 39 ++++ packages/eslint-plugin-actual/lib/index.js | 1 + .../__tests__/no-secure-context-apis.test.js | 102 ++++++++++ .../lib/rules/no-secure-context-apis.js | 113 +++++++++++ packages/eslint-plugin-actual/package.json | 5 +- yarn.lock | 8 + 10 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 packages/eslint-plugin-actual/lib/data/generate-secure-context-apis.mjs create mode 100644 packages/eslint-plugin-actual/lib/data/secure-context-apis.json create mode 100644 packages/eslint-plugin-actual/lib/rules/__tests__/no-secure-context-apis.test.js create mode 100644 packages/eslint-plugin-actual/lib/rules/no-secure-context-apis.js diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0dd92f15b6..d95c006b26 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -39,6 +39,8 @@ jobs: run: yarn constraints - name: Check tsconfig project references are in sync run: yarn check:tsconfig-references + - name: Check secure-context API deny list is up to date + run: yarn workspace eslint-plugin-actual run check:secure-context-list lint: needs: setup runs-on: ubuntu-latest diff --git a/.oxlintrc.json b/.oxlintrc.json index 0a57484653..d8f8425a41 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -36,6 +36,7 @@ "actual/prefer-const": "error", "actual/no-anchor-tag": "error", "actual/no-react-default-import": "error", + "actual/no-secure-context-apis": "error", "actual/prefer-subpath-imports": "error", "actual/enforce-boundaries": "error", "actual/no-extraneous-dependencies": "error", @@ -371,6 +372,7 @@ "rules": { "actual/no-untranslated-strings": "off", "actual/prefer-logger-over-console": "off", + "actual/no-secure-context-apis": "off", "typescript/unbound-method": "off" } }, @@ -434,6 +436,58 @@ "typescript/no-unsafe-unary-minus": "error", "typescript/no-unsafe-type-assertion": "error" } + }, + // Server-side / Node-only code runs in environments where secure-context + // restrictions don't apply. + { + "files": [ + "packages/sync-server/**/*", + "packages/api/**/*", + "packages/cli/**/*", + "packages/ci-actions/**/*", + "packages/loot-core/src/server/**/*", + "packages/loot-core/src/platform/server/**/*", + "packages/loot-core/migrations/**/*", + "packages/api/migrations/**/*", + "packages/desktop-electron/**/*", + "packages/eslint-plugin-actual/**/*" + ], + "rules": { + "actual/no-secure-context-apis": "off" + } + }, + // Grandfathered violators of `actual/no-secure-context-apis`. These + // files predate the rule and use secure-context-only Web APIs directly; + // adding them to this list keeps existing code working while the rule + // prevents *new* call sites. Migrate a file off the list by replacing + // the offending API with a safe wrapper, then remove its entry. + { + "files": [ + "packages/crdt/src/crdt/timestamp.ts", + "packages/desktop-client/src/accounts/mutations.ts", + "packages/desktop-client/src/budget/mutations.ts", + "packages/desktop-client/src/components/accounts/Account.tsx", + "packages/desktop-client/src/components/formula/QueryManager.tsx", + "packages/desktop-client/src/components/rules/RuleEditor.tsx", + "packages/desktop-client/src/components/settings/Encryption.tsx", + "packages/desktop-client/src/notes/DesktopLinkedNotes.tsx", + "packages/desktop-client/src/notes/MobileLinkedNotes.tsx", + "packages/desktop-client/src/notifications/notificationsSlice.ts", + "packages/desktop-client/src/payees/location-adapters.ts", + "packages/desktop-client/src/payees/mutations.ts", + "packages/desktop-client/src/reports/mutations.ts", + "packages/desktop-client/src/tags/mutations.ts", + "packages/desktop-client/src/util/ruleUtils.ts", + "packages/loot-core/src/mocks/budget.ts", + "packages/loot-core/src/mocks/index.ts", + "packages/loot-core/src/platform/client/connection/index.electron.ts", + "packages/loot-core/src/platform/client/connection/index.ts", + "packages/loot-core/src/platform/client/undo/index.ts", + "packages/loot-core/src/shared/transactions.ts" + ], + "rules": { + "actual/no-secure-context-apis": "off" + } } ] } diff --git a/AGENTS.md b/AGENTS.md index 6f74abc866..2f2280dda1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -341,6 +341,7 @@ Always maintain newlines between import groups. - Import from `uuid` without destructuring: use `import { v4 as uuidv4 } from 'uuid'` - Import colors directly - use theme instead - Import `@actual-app/web/*` in `loot-core` +- Use Web APIs that are only available in secure contexts (e.g. `crypto.randomUUID`, `crypto.subtle`, `navigator.clipboard`, `navigator.serviceWorker`) in browser-side code. Actual must run over plain HTTP from non-localhost hosts. The `actual/no-secure-context-apis` lint rule enforces this; the deny list is generated from `@mdn/browser-compat-data` by `packages/eslint-plugin-actual/lib/data/generate-secure-context-apis.mjs`. **Git Commands:** diff --git a/packages/eslint-plugin-actual/lib/data/generate-secure-context-apis.mjs b/packages/eslint-plugin-actual/lib/data/generate-secure-context-apis.mjs new file mode 100644 index 0000000000..bc419d255a --- /dev/null +++ b/packages/eslint-plugin-actual/lib/data/generate-secure-context-apis.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +// Generates secure-context-apis.json — the deny list consumed by the +// `actual/no-secure-context-apis` lint rule. +// +// Source 1: @mdn/browser-compat-data. Each BCD feature can carry a +// `secure_context_required` *subfeature* (a child key whose presence means +// "this feature is gated to secure contexts"). See +// https://github.com/mdn/browser-compat-data/issues/190. We walk `api.*` +// and collect entries that have one, translating their BCD path into a JS +// global access path via the SEEDS map below. +// +// Source 2: a small manual augment list. BCD's secure-context coverage is +// incomplete (issue tracked at https://github.com/mdn/browser-compat-data/issues/4696 +// since 2020). Notably, `crypto.randomUUID` — the API that motivated this +// rule (actualbudget/actual#7715) — is not currently tagged. The augment +// list covers known gaps until BCD catches up. +// +// Usage: +// node generate-secure-context-apis.mjs # write the file +// node generate-secure-context-apis.mjs --check # exit 1 if file would change + +import { readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const bcd = require('@mdn/browser-compat-data'); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUTPUT_PATH = resolve(__dirname, 'secure-context-apis.json'); + +// Map a BCD interface name (the first segment after `api.`) to the JS +// global access path through which it is reached. Interfaces NOT in this +// map are skipped — most BCD interfaces aren't directly reachable from a +// browser global (they're return values of other APIs) and would produce +// false positives. +const SEEDS = { + // Top-level globals + caches: 'caches', + Cache: 'Cache', + Notification: 'Notification', + PaymentRequest: 'PaymentRequest', + PaymentManager: 'PaymentManager', + PublicKeyCredential: 'PublicKeyCredential', + SharedWorker: 'SharedWorker', + PushManager: 'PushManager', + PushSubscription: 'PushSubscription', + EyeDropper: 'EyeDropper', + Gamepad: 'Gamepad', + GamepadButton: 'GamepadButton', + GamepadEvent: 'GamepadEvent', + GamepadHapticActuator: 'GamepadHapticActuator', + GamepadPose: 'GamepadPose', + NDEFMessage: 'NDEFMessage', + NDEFReader: 'NDEFReader', + NDEFReadingEvent: 'NDEFReadingEvent', + NDEFRecord: 'NDEFRecord', + PaymentRequestUpdateEvent: 'PaymentRequestUpdateEvent', + PresentationRequest: 'PresentationRequest', + BatteryManager: 'BatteryManager', + ServiceWorker: 'ServiceWorker', + ServiceWorkerRegistration: 'ServiceWorkerRegistration', + + // Reached through `crypto.*` + Crypto: 'crypto', + SubtleCrypto: 'crypto.subtle', + + // Reached through `navigator.*` + Navigator: 'navigator', + Clipboard: 'navigator.clipboard', + ClipboardItem: 'ClipboardItem', + ServiceWorkerContainer: 'navigator.serviceWorker', + MediaDevices: 'navigator.mediaDevices', + Bluetooth: 'navigator.bluetooth', + USB: 'navigator.usb', + HID: 'navigator.hid', + Serial: 'navigator.serial', + WakeLock: 'navigator.wakeLock', + CredentialsContainer: 'navigator.credentials', + StorageManager: 'navigator.storage', + LockManager: 'navigator.locks', + Geolocation: 'navigator.geolocation', + GeolocationCoordinates: 'navigator.geolocation', + GeolocationPosition: 'navigator.geolocation', + GeolocationPositionError: 'navigator.geolocation', +}; + +// Manual augment list for APIs that ARE secure-context-only per spec/MDN +// but aren't yet tagged in BCD. Keep this list short; prefer fixing BCD +// upstream when possible. Each entry must be a JS global access path that +// matches the lint rule's chain matcher. +const MANUAL_AUGMENT = [ + // crypto.randomUUID requires a secure context per the WebCrypto spec; the + // entire reason this rule exists. https://github.com/actualbudget/actual/issues/7715 + 'crypto.randomUUID', + // The Async Clipboard API requires a secure context. + 'navigator.clipboard', + // ServiceWorkerContainer access requires a secure context. + 'navigator.serviceWorker', + // Web Locks API. + 'navigator.locks', + // Storage Manager (estimate/persist/persisted). + 'navigator.storage', + // Credential Management. + 'navigator.credentials', + // Wake Lock. + 'navigator.wakeLock', + // WebHID, WebUSB, WebSerial, Web Bluetooth — covered by BCD interface + // tags via SEEDS but listed here too in case a member is accessed in a + // way that bypasses the interface root match. + 'navigator.bluetooth', + 'navigator.usb', + 'navigator.hid', + 'navigator.serial', +]; + +const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +function hasSecureContextRequired(node) { + return Boolean( + node && typeof node === 'object' && node.secure_context_required, + ); +} + +// Walk a BCD subtree starting at `node`. `bcdPathSegments` is the path so +// far (excluding the `api` root and the interface root). Calls `visit` for +// every entry that requires a secure context. +function walk(node, bcdPathSegments, visit) { + if (!node || typeof node !== 'object') return; + + if (hasSecureContextRequired(node)) { + visit(bcdPathSegments); + } + + for (const key of Object.keys(node)) { + if (key === '__compat') continue; + if (key === 'secure_context_required') continue; + if (key.endsWith('_event') || key.endsWith('_static')) continue; + if (!IDENT_RE.test(key)) continue; + + const child = node[key]; + if (!child || typeof child !== 'object') continue; + walk(child, [...bcdPathSegments, key], visit); + } +} + +function generate() { + const output = new Set(MANUAL_AUGMENT); + + for (const [iface, accessPath] of Object.entries(SEEDS)) { + const ifaceNode = bcd.api[iface]; + if (!ifaceNode) continue; + + walk(ifaceNode, [], segments => { + const memberPath = segments.length + ? `${accessPath}.${segments.join('.')}` + : accessPath; + output.add(memberPath); + }); + } + + return [...output].sort(); +} + +function format(list) { + return JSON.stringify(list, null, 2) + '\n'; +} + +const generated = format(generate()); + +if (process.argv.includes('--check')) { + let existing = ''; + try { + existing = readFileSync(OUTPUT_PATH, 'utf8'); + } catch { + // file missing => drift + } + if (existing !== generated) { + process.stderr.write( + `secure-context-apis.json is out of date.\n` + + `Run \`yarn workspace eslint-plugin-actual run generate:secure-context-list\` and commit the result.\n`, + ); + process.exit(1); + } +} else { + writeFileSync(OUTPUT_PATH, generated); +} diff --git a/packages/eslint-plugin-actual/lib/data/secure-context-apis.json b/packages/eslint-plugin-actual/lib/data/secure-context-apis.json new file mode 100644 index 0000000000..79c3ece2a2 --- /dev/null +++ b/packages/eslint-plugin-actual/lib/data/secure-context-apis.json @@ -0,0 +1,39 @@ +[ + "BatteryManager", + "EyeDropper", + "Gamepad", + "GamepadButton", + "GamepadEvent", + "GamepadHapticActuator", + "GamepadPose", + "NDEFMessage", + "NDEFReader", + "NDEFReadingEvent", + "NDEFRecord", + "Notification", + "PaymentManager", + "PaymentRequestUpdateEvent", + "PresentationRequest", + "caches", + "crypto.randomUUID", + "crypto.subtle", + "navigator.activeVRDisplays", + "navigator.bluetooth", + "navigator.clipboard", + "navigator.credentials", + "navigator.geolocation", + "navigator.getBattery", + "navigator.getGamepads", + "navigator.hid", + "navigator.locks", + "navigator.mediaDevices", + "navigator.mediaDevices.getUserMedia", + "navigator.registerProtocolHandler", + "navigator.requestMIDIAccess", + "navigator.serial", + "navigator.serviceWorker", + "navigator.storage", + "navigator.usb", + "navigator.userAgentData", + "navigator.wakeLock" +] diff --git a/packages/eslint-plugin-actual/lib/index.js b/packages/eslint-plugin-actual/lib/index.js index 9a187798b5..944548b558 100644 --- a/packages/eslint-plugin-actual/lib/index.js +++ b/packages/eslint-plugin-actual/lib/index.js @@ -14,6 +14,7 @@ module.exports = eslintCompatPlugin({ 'prefer-const': require('./rules/prefer-const'), 'no-anchor-tag': require('./rules/no-anchor-tag'), 'no-react-default-import': require('./rules/no-react-default-import'), + 'no-secure-context-apis': require('./rules/no-secure-context-apis'), 'prefer-subpath-imports': require('./rules/prefer-subpath-imports'), 'enforce-boundaries': require('./rules/enforce-boundaries'), 'no-extraneous-dependencies': require('./rules/no-extraneous-dependencies'), diff --git a/packages/eslint-plugin-actual/lib/rules/__tests__/no-secure-context-apis.test.js b/packages/eslint-plugin-actual/lib/rules/__tests__/no-secure-context-apis.test.js new file mode 100644 index 0000000000..8cc0bc9a92 --- /dev/null +++ b/packages/eslint-plugin-actual/lib/rules/__tests__/no-secure-context-apis.test.js @@ -0,0 +1,102 @@ +import { runClassic } from 'eslint-vitest-rule-tester'; + +import plugin from '../../index'; +const rule = plugin.rules['no-secure-context-apis']; + +void runClassic( + 'no-secure-context-apis', + rule, + { + valid: [ + // Different `crypto` member that doesn't require a secure context. + 'crypto.getRandomValues(new Uint8Array(16));', + // Locally-named property access shouldn't false-positive. + 'const obj = { crypto: { randomUUID: () => 1 } }; obj.crypto.randomUUID();', + // Importing a UUID library is the suggested replacement. + "import { v4 as uuidv4 } from 'uuid'; uuidv4();", + // Computed access — we deliberately don't try to resolve dynamic keys. + "crypto['randomUUID'];", + // Unrelated bare identifier with the same name as a deny entry but used + // as a local variable (declarations are not flagged). + 'function f(Notification) { return Notification; }', + ], + invalid: [ + { + code: 'crypto.randomUUID();', + output: null, + errors: [ + { + messageId: 'secureContextOnly', + data: { path: 'crypto.randomUUID' }, + }, + ], + }, + { + code: 'const id = crypto.randomUUID();', + output: null, + errors: [ + { + messageId: 'secureContextOnly', + data: { path: 'crypto.randomUUID' }, + }, + ], + }, + { + code: "await navigator.clipboard.writeText('hi');", + output: null, + errors: [ + { + messageId: 'secureContextOnly', + data: { path: 'navigator.clipboard' }, + }, + ], + }, + { + code: 'crypto.subtle.encrypt(algo, key, data);', + output: null, + errors: [ + { + messageId: 'secureContextOnly', + data: { path: 'crypto.subtle' }, + }, + ], + }, + { + code: "new Notification('title');", + output: null, + errors: [ + { + messageId: 'secureContextOnly', + data: { path: 'Notification' }, + }, + ], + }, + { + code: "caches.open('cache-v1');", + output: null, + errors: [ + { + messageId: 'secureContextOnly', + data: { path: 'caches' }, + }, + ], + }, + { + code: "navigator.serviceWorker.register('/sw.js');", + output: null, + errors: [ + { + messageId: 'secureContextOnly', + data: { path: 'navigator.serviceWorker' }, + }, + ], + }, + ], + }, + { + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + }, +); diff --git a/packages/eslint-plugin-actual/lib/rules/no-secure-context-apis.js b/packages/eslint-plugin-actual/lib/rules/no-secure-context-apis.js new file mode 100644 index 0000000000..6d56bea5d1 --- /dev/null +++ b/packages/eslint-plugin-actual/lib/rules/no-secure-context-apis.js @@ -0,0 +1,113 @@ +// Forbids usage of Web APIs that are only defined in secure contexts +// (HTTPS or localhost). Actual must keep working when self-hosted over +// plain HTTP, so introducing new direct uses of these APIs is a regression +// risk. The deny list is generated from `@mdn/browser-compat-data` by +// `lib/data/generate-secure-context-apis.mjs`. +// +// The rule fires on member access whose chain prefix matches a deny entry, +// and on `new Foo(...)` for constructors named in the deny list. + +const denyPaths = require('../data/secure-context-apis.json'); + +// Index deny paths by their root identifier so we don't re-scan the whole +// list on every node. Each entry stores the parsed segments for prefix +// comparison. +const indexByRoot = new Map(); +for (const entry of denyPaths) { + const segments = entry.split('.'); + const [root] = segments; + if (!indexByRoot.has(root)) indexByRoot.set(root, []); + indexByRoot.get(root).push({ path: entry, segments }); +} + +function getMemberChain(node) { + const parts = []; + let cur = node; + while (cur && cur.type === 'MemberExpression') { + if (cur.computed) return null; + if (!cur.property || cur.property.type !== 'Identifier') return null; + parts.unshift(cur.property.name); + cur = cur.object; + } + if (!cur || cur.type !== 'Identifier') return null; + parts.unshift(cur.name); + return parts; +} + +function findMatch(chain) { + const candidates = indexByRoot.get(chain[0]); + if (!candidates) return null; + for (const candidate of candidates) { + if (candidate.segments.length > chain.length) continue; + let matches = true; + for (let i = 0; i < candidate.segments.length; i++) { + if (candidate.segments[i] !== chain[i]) { + matches = false; + break; + } + } + if (matches) return candidate.path; + } + return null; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Forbid Web APIs that are only available in secure contexts; Actual must run over plain HTTP', + }, + fixable: null, + schema: [], + messages: { + secureContextOnly: + '`{{path}}` is only available in secure contexts (HTTPS or localhost) and throws on plain HTTP. Actual must work in insecure contexts; do not introduce new direct uses.', + }, + }, + + createOnce(context) { + return { + MemberExpression(node) { + // Only fire on the outermost MemberExpression of a chain so we don't + // double-report nested accesses (e.g. navigator.clipboard.writeText + // matches both `navigator.clipboard` and the longer chain). + if ( + node.parent && + node.parent.type === 'MemberExpression' && + node.parent.object === node + ) { + return; + } + + const chain = getMemberChain(node); + if (!chain) return; + + const match = findMatch(chain); + if (match) { + context.report({ + node, + messageId: 'secureContextOnly', + data: { path: match }, + }); + } + }, + NewExpression(node) { + // `new Notification(...)`, `new PaymentRequest(...)`, etc. — a + // bare-identifier constructor whose name is in the deny list. + if (!node.callee || node.callee.type !== 'Identifier') return; + const candidates = indexByRoot.get(node.callee.name); + if (!candidates) return; + const match = candidates.find(c => c.segments.length === 1); + if (match) { + context.report({ + node, + messageId: 'secureContextOnly', + data: { path: match.path }, + }); + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin-actual/package.json b/packages/eslint-plugin-actual/package.json index e87086cabc..a47bdbf513 100644 --- a/packages/eslint-plugin-actual/package.json +++ b/packages/eslint-plugin-actual/package.json @@ -5,9 +5,12 @@ "main": "./lib/index.js", "exports": "./lib/index.js", "scripts": { - "test": "vitest --run" + "test": "vitest --run", + "generate:secure-context-list": "node lib/data/generate-secure-context-apis.mjs", + "check:secure-context-list": "node lib/data/generate-secure-context-apis.mjs --check" }, "devDependencies": { + "@mdn/browser-compat-data": "^6.0.0", "@oxlint/plugins": "^1.60.0", "eslint-vitest-rule-tester": "^3.1.0", "vitest": "^4.1.2" diff --git a/yarn.lock b/yarn.lock index cee3bed87a..7090413a59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5029,6 +5029,13 @@ __metadata: languageName: node linkType: hard +"@mdn/browser-compat-data@npm:^6.0.0": + version: 6.1.5 + resolution: "@mdn/browser-compat-data@npm:6.1.5" + checksum: 10/4fb08f12a8471d9b1b3f10aa0b674c0ab24edc2aa10abf284a4c407c2bd6d55b5ed469e21f465677ecc08eac288c360d751fb02e8147722282609943a2417959 + languageName: node + linkType: hard + "@mdx-js/mdx@npm:^3.0.0": version: 3.1.1 resolution: "@mdx-js/mdx@npm:3.1.1" @@ -15631,6 +15638,7 @@ __metadata: version: 0.0.0-use.local resolution: "eslint-plugin-actual@workspace:packages/eslint-plugin-actual" dependencies: + "@mdn/browser-compat-data": "npm:^6.0.0" "@oxlint/plugins": "npm:^1.60.0" eslint-vitest-rule-tester: "npm:^3.1.0" vitest: "npm:^4.1.2"