Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
fc54c3e856 [AI] Add lint guardrail blocking secure-context-only Web APIs
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
2026-05-07 22:08:02 +00:00
10 changed files with 512 additions and 1 deletions

View File

@@ -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

View File

@@ -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"
}
}
]
}

View File

@@ -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:**

View File

@@ -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);
}

View File

@@ -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"
]

View File

@@ -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'),

View File

@@ -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',
},
},
);

View File

@@ -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 },
});
}
},
};
},
};

View File

@@ -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"

View File

@@ -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"