diff --git a/.github/workflows/studio-lint-ratchet.yml b/.github/workflows/studio-lint-ratchet.yml index 9a3025584cb..bf34ef2b96c 100644 --- a/.github/workflows/studio-lint-ratchet.yml +++ b/.github/workflows/studio-lint-ratchet.yml @@ -44,3 +44,8 @@ jobs: - name: Run ratchet script run: pnpm --filter studio run lint:ratchet + + - name: Run type-aware ratchet script + env: + NODE_OPTIONS: --max-old-space-size=4096 + run: pnpm --filter studio run lint:ratchet:type-checks diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index c74dc59c540..94ae556e81c 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -6,7 +6,8 @@ "@typescript-eslint/no-explicit-any": 1090, "no-restricted-imports": 0, "no-restricted-exports": 271, - "react/no-unstable-nested-components": 64 + "react/no-unstable-nested-components": 64, + "studio/require-safe-sql-fragment": 158 }, "ruleFiles": { "react-hooks/exhaustive-deps": { @@ -1012,6 +1013,148 @@ "components/ui/SqlEditor.tsx": 1, "pages/integrations/vercel/[slug]/deploy-button/new-project.tsx": 1, "pages/project/[ref]/observability/database.tsx": 1 + }, + "studio/require-safe-sql-fragment": { + "components/interfaces/Auth/Hooks/CreateHookSheet.tsx": 1, + "components/interfaces/Auth/Hooks/HooksListing.tsx": 1, + "components/interfaces/Docs/Description.tsx": 1, + "components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts": 2, + "components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils.ts": 1, + "components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx": 1, + "components/interfaces/QueryPerformance/useQueryPerformanceQuery.ts": 1, + "components/interfaces/Reports/ReportBlock/ReportBlock.tsx": 1, + "components/interfaces/Settings/API/DataApiEnableSwitch.utils.ts": 1, + "components/interfaces/Storage/PublicBucketWarning.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx": 12, + "components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx": 1, + "components/ui/EditorPanel/EditorPanel.tsx": 1, + "data/api-settings/create-and-expose-api-schema-mutation.ts": 1, + "data/auth/index-worker-status-query.ts": 1, + "data/auth/user-query.ts": 1, + "data/auth/user-search-indexes-query.ts": 1, + "data/auth/users-count-query.ts": 1, + "data/auth/users-infinite-query.ts": 1, + "data/config/disk-breakdown-query.ts": 1, + "data/database-columns/database-column-create-mutation.ts": 1, + "data/database-columns/database-column-delete-mutation.ts": 1, + "data/database-columns/database-column-update-mutation.ts": 1, + "data/database-cron-jobs/database-cron-job-query.ts": 1, + "data/database-cron-jobs/database-cron-job-run-mutation.ts": 1, + "data/database-cron-jobs/database-cron-jobs-count-estimate-query.ts": 1, + "data/database-cron-jobs/database-cron-jobs-count-query.ts": 1, + "data/database-cron-jobs/database-cron-jobs-create-mutation.ts": 1, + "data/database-cron-jobs/database-cron-jobs-delete-mutation.ts": 1, + "data/database-cron-jobs/database-cron-jobs-infinite-query.ts": 1, + "data/database-cron-jobs/database-cron-jobs-minimal-infinite-query.ts": 1, + "data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts": 1, + "data/database-cron-jobs/database-cron-jobs-toggle-mutation.ts": 1, + "data/database-cron-jobs/database-cron-timezone-query.ts": 1, + "data/database-cron-jobs/schedule-clean-up-mutation.ts": 1, + "data/database-event-triggers/database-event-trigger-create-mutation.ts": 1, + "data/database-event-triggers/database-event-trigger-delete-mutation.ts": 1, + "data/database-event-triggers/database-event-triggers-query.ts": 1, + "data/database-extensions/database-extension-disable-mutation.ts": 1, + "data/database-extensions/database-extension-enable-mutation.ts": 1, + "data/database-extensions/database-extensions-query.ts": 1, + "data/database-functions/database-functions-create-mutation.ts": 1, + "data/database-functions/database-functions-delete-mutation.ts": 1, + "data/database-functions/database-functions-query.ts": 1, + "data/database-functions/database-functions-update-mutation.ts": 1, + "data/database-indexes/index-create-mutation.ts": 1, + "data/database-indexes/index-delete-mutation.ts": 1, + "data/database-indexes/indexes-query.ts": 1, + "data/database-integrations/stripe/sync-state-query.ts": 1, + "data/database-policies/database-policy-create-mutation.ts": 1, + "data/database-policies/database-policy-delete-mutation.ts": 1, + "data/database-policies/database-policy-update-mutation.ts": 1, + "data/database-publications/database-publications-create-mutation.ts": 1, + "data/database-publications/database-publications-update-mutation.ts": 1, + "data/database-queues/database-queue-messages-archive-mutation.ts": 1, + "data/database-queues/database-queue-messages-delete-mutation.ts": 1, + "data/database-queues/database-queue-messages-infinite-query.ts": 1, + "data/database-queues/database-queue-messages-read-mutation.ts": 1, + "data/database-queues/database-queue-messages-send-mutation.ts": 1, + "data/database-queues/database-queues-create-mutation.ts": 1, + "data/database-queues/database-queues-delete-mutation.ts": 1, + "data/database-queues/database-queues-expose-postgrest-status-query.ts": 1, + "data/database-queues/database-queues-metrics-query.ts": 2, + "data/database-queues/database-queues-purge-mutation.ts": 1, + "data/database-queues/database-queues-query.ts": 1, + "data/database-queues/database-queues-toggle-postgrest-mutation.ts": 1, + "data/database-roles/database-role-create-mutation.ts": 1, + "data/database-roles/database-role-delete-mutation.ts": 1, + "data/database-roles/database-role-update-mutation.ts": 1, + "data/database-roles/database-roles-query.ts": 1, + "data/database-triggers/database-trigger-create-mutation.ts": 1, + "data/database-triggers/database-trigger-delete-mutation.ts": 1, + "data/database-triggers/database-trigger-update-mutation.ts": 1, + "data/database-triggers/database-trigger-update-transaction-mutation.ts": 1, + "data/database/constraints-query.ts": 1, + "data/database/database-size-query.ts": 1, + "data/database/entity-definitions-query.ts": 1, + "data/database/foreign-key-constraints-query.ts": 1, + "data/database/keywords-query.ts": 1, + "data/database/max-connections-query.ts": 1, + "data/database/migrations-query.ts": 1, + "data/database/retrieve-index-advisor-result-query.ts": 1, + "data/database/retrieve-index-from-select-query.ts": 2, + "data/database/schema-create-mutation.ts": 1, + "data/database/schemas-query.ts": 1, + "data/database/supamonitor-enabled-query.ts": 1, + "data/database/table-columns-query.ts": 1, + "data/database/table-definition-query.ts": 1, + "data/database/table-index-advisor-query.ts": 1, + "data/database/view-definition-query.ts": 1, + "data/entity-types/entity-types-infinite-query.ts": 1, + "data/enumerated-types/enumerated-type-create-mutation.ts": 1, + "data/enumerated-types/enumerated-type-delete-mutation.ts": 1, + "data/enumerated-types/enumerated-type-update-mutation.ts": 1, + "data/fdw/fdw-create-mutation.ts": 1, + "data/fdw/fdw-delete-mutation.ts": 1, + "data/fdw/fdw-drop-foreign-table-mutation.ts": 1, + "data/fdw/fdw-import-foreign-schema-mutation.ts": 1, + "data/fdw/fdw-update-mutation.ts": 1, + "data/fdw/fdws-query.ts": 1, + "data/privileges/default-privileges-query.ts": 1, + "data/privileges/exposed-function-counts-query.ts": 1, + "data/privileges/exposed-functions-infinite-query.ts": 1, + "data/privileges/exposed-table-counts-query.ts": 1, + "data/privileges/exposed-tables-infinite-query.ts": 1, + "data/privileges/table-api-access-mutation.ts": 1, + "data/privileges/table-privileges-grant-mutation.ts": 1, + "data/privileges/table-privileges-query.ts": 1, + "data/privileges/table-privileges-revoke-mutation.ts": 1, + "data/privileges/update-default-privileges-mutation.ts": 1, + "data/privileges/update-exposed-entities-mutation.ts": 1, + "data/read-replicas/replica-lag-query.ts": 1, + "data/sql/abort-query-mutation.ts": 1, + "data/sql/execute-sql-query.ts": 1, + "data/sql/ongoing-queries-query.ts": 1, + "data/storage/bucket-prefix-delete-mutation.ts": 1, + "data/storage/buckets-max-size-limit-query.ts": 2, + "data/storage/public-buckets-with-select-policies-query.ts": 1, + "data/table-editor/table-editor-query.ts": 1, + "data/table-rows/get-cell-value-mutation.ts": 1, + "data/table-rows/operation-queue-save-mutation.ts": 1, + "data/table-rows/table-row-create-mutation.ts": 1, + "data/table-rows/table-row-delete-all-mutation.ts": 1, + "data/table-rows/table-row-delete-mutation.tsx": 1, + "data/table-rows/table-row-truncate-mutation.ts": 1, + "data/table-rows/table-row-update-mutation.ts": 1, + "data/table-rows/table-rows-count-query.ts": 1, + "data/table-rows/table-rows-query.ts": 3, + "data/tables/table-names-query.ts": 1, + "data/tables/tables-roles-access-query.ts": 1, + "data/vault/vault-secret-create-mutation.ts": 1, + "data/vault/vault-secret-decrypted-value-query.ts": 2, + "data/vault/vault-secret-delete-mutation.ts": 1, + "data/vault/vault-secret-update-mutation.ts": 1, + "data/vault/vault-secrets-query.ts": 1, + "hooks/analytics/useDbQuery.tsx": 1, + "lib/ai/tools/fallback-tools.ts": 1, + "pages/api/ai/code/complete.ts": 1, + "pages/api/ai/sql/generate-v4.ts": 1, + "state/role-impersonation-state.tsx": 1 } } } diff --git a/apps/studio/eslint-rules/require-safe-sql-fragment.js b/apps/studio/eslint-rules/require-safe-sql-fragment.js new file mode 100644 index 00000000000..59318430f77 --- /dev/null +++ b/apps/studio/eslint-rules/require-safe-sql-fragment.js @@ -0,0 +1,70 @@ +/** + * ESLint rule to require SafeSqlFragment (not a plain string) for the sql argument of executeSql. + * + * During migration, executeSql still accepts string (a supertype of SafeSqlFragment), but new + * call sites should use SafeSqlFragment to avoid SQL injection risks and to be ready for when + * the type is tightened. + * + * BAD: + * executeSql({ projectRef, sql: `SELECT * FROM ${table}` }) + * const query = buildQuery() // returns string + * executeSql({ projectRef, sql: query }) + * + * GOOD: + * executeSql({ projectRef, sql: safeSql`SELECT * FROM ${ident(table)}` }) + * executeSql({ projectRef, sql: keyword('SELECT 1') }) + */ + +const { ESLintUtils } = require('@typescript-eslint/utils') + +/** @type {import('@typescript-eslint/utils').TSESLint.RuleModule<'requireSafeSqlFragment'>} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Require executeSql to be called with SafeSqlFragment, not a plain string. Use safeSql`...`, ident(), literal(), or keyword() to create a SafeSqlFragment.', + recommended: true, + }, + messages: { + requireSafeSqlFragment: + 'The sql argument to executeSql must be SafeSqlFragment, not a plain string. Use safeSql`...`, ident(), literal(), or keyword() to construct it safely.', + }, + schema: [], + }, + + create(context) { + const services = ESLintUtils.getParserServices(context) + const checker = services.program.getTypeChecker() + + return { + CallExpression(node) { + const callee = node.callee + const isExecuteSql = + (callee.type === 'Identifier' && callee.name === 'executeSql') || + (callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + callee.property.name === 'executeSql') + + if (!isExecuteSql) return + + const firstArg = node.arguments[0] + if (!firstArg || firstArg.type !== 'ObjectExpression') return + + const sqlProp = firstArg.properties.find( + (prop) => + prop.type === 'Property' && prop.key.type === 'Identifier' && prop.key.name === 'sql' + ) + if (!sqlProp || sqlProp.type !== 'Property') return + + const tsNode = services.esTreeNodeToTSNodeMap.get(sqlProp.value) + const type = checker.getTypeAtLocation(tsNode) + const hasBrand = checker.getPropertyOfType(type, '__safeSqlFragmentBrand') !== undefined + + if (!hasBrand) { + context.report({ node: sqlProp.value, messageId: 'requireSafeSqlFragment' }) + } + }, + } + }, +} diff --git a/apps/studio/eslint.type-checks.config.cjs b/apps/studio/eslint.type-checks.config.cjs new file mode 100644 index 00000000000..e5bb7209a47 --- /dev/null +++ b/apps/studio/eslint.type-checks.config.cjs @@ -0,0 +1,36 @@ +/** + * Separate ESLint config for rules that require TypeScript type information. + * + * Run via: pnpm lint:type-checks + * + * Kept separate from eslint.config.cjs because loading a full TypeScript program + * (project: true) is memory-intensive and would slow down or OOM the main lint run. + */ + +const { defineConfig } = require('eslint/config') +const tsparser = require('@typescript-eslint/parser') +const requireSafeSqlFragment = require('./eslint-rules/require-safe-sql-fragment') + +const studioPlugin = { + rules: { + 'require-safe-sql-fragment': requireSafeSqlFragment, + }, +} + +module.exports = defineConfig([ + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parser: tsparser, + parserOptions: { + project: true, + }, + }, + plugins: { + studio: studioPlugin, + }, + rules: { + 'studio/require-safe-sql-fragment': 'warn', + }, + }, +]) diff --git a/apps/studio/package.json b/apps/studio/package.json index 6590ad159a8..70d220777a8 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -9,6 +9,7 @@ "start": "next start -p 8082", "lint": "eslint .", "lint:ratchet": "tsx scripts/ratchet-eslint-rules.ts --rule react-hooks/exhaustive-deps --rule import/no-anonymous-default-export --rule @tanstack/query/exhaustive-deps --rule @typescript-eslint/no-explicit-any --rule no-restricted-imports --rule no-restricted-exports --rule react/no-unstable-nested-components", + "lint:ratchet:type-checks": "tsx scripts/ratchet-eslint-rules.ts --rule studio/require-safe-sql-fragment --eslint-args \"--config eslint.type-checks.config.cjs .\"", "clean": "rimraf node_modules tsconfig.tsbuildinfo .next .turbo", "test": "vitest --run --coverage", "test:watch": "vitest watch", @@ -177,6 +178,7 @@ "@types/sqlstring": "^2.3.0", "@types/uuid": "^8.3.4", "@types/zxcvbn": "^4.4.1", + "@typescript-eslint/utils": "8.48.0", "@vitest/coverage-v8": "^3.2.0", "@vitest/ui": "^3.2.0", "api-types": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42a74a94cfd..d64f09101b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1277,6 +1277,9 @@ importers: '@types/zxcvbn': specifier: ^4.4.1 version: 4.4.2 + '@typescript-eslint/utils': + specifier: 8.48.0 + version: 8.48.0(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) '@vitest/coverage-v8': specifier: ^3.2.0 version: 3.2.4(supports-color@8.1.1)(vitest@3.2.4) @@ -1321,7 +1324,7 @@ importers: version: 2.11.3(@types/node@22.13.14)(typescript@6.0.2) next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@16.1.7(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) node-mocks-http: specifier: ^1.17.2 version: 1.17.2(@types/node@22.13.14) @@ -2274,7 +2277,7 @@ importers: version: 18.3.0 next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@16.1.7(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) tsconfig: specifier: workspace:* version: link:../tsconfig @@ -2837,7 +2840,7 @@ importers: version: link:../api-types next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@16.1.7(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) tsx: specifier: 'catalog:' version: 4.20.3 @@ -33750,7 +33753,7 @@ snapshots: dependencies: js-yaml-loader: 1.2.2 - next-router-mock@0.9.13(next@16.1.7(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): + next-router-mock@0.9.13(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): dependencies: next: 16.1.7(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1