Files
supabase/apps/studio/components/interfaces/Linter/Linter.utils.tsx
oniani1 a95b6f9013 fix(studio): encode special characters in database advisor lint links (#45385)
The link builders in
`apps/studio/components/interfaces/Linter/Linter.utils.tsx` interpolate
`metadata.schema` and `metadata.name` directly into URL query strings. A
schema or table name with `&`, `=`, `+`, or `#` breaks the destination
filter on the linked page because `URLSearchParams` stops at the bare
`&` and decodes `+` to a space.

The `public_bucket_allows_listing` lint at line 338 already wraps
`bucket_id` in `encodeURIComponent`. The other 15 builders did not. This
wraps each `metadata?.schema` and `metadata?.name` interpolation with
`encodeURIComponent(value ?? '')` to match.

Added `Linter.utils.test.tsx` that constructs links with a schema
`a&b=c` and a name `d e+f` and asserts `URLSearchParams` round-trips
them. The bucket precedent is also covered.

Closes #45384

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved URL encoding for navigation links in the linter interface to
ensure proper handling of special characters in database, schema, and
table names.

* **Tests**
* Added test coverage for URL generation functionality in the linter
utility.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 22:02:07 +08:00

500 lines
20 KiB
TypeScript

import {
Box,
Clock,
Eye,
Lock,
LockIcon,
Ruler,
Scaling,
Table2,
TextSearch,
Unlock,
User,
} from 'lucide-react'
import Link from 'next/link'
import { Badge, Button } from 'ui'
import { asGraphqlExposureLint, GraphqlExposureLintCTA } from './GraphqlExposureLintCTA'
import { LINTER_LEVELS, LintInfo } from '@/components/interfaces/Linter/Linter.constants'
import { Lint, LINT_TYPES } from '@/data/lint/lint-query'
import { DOCS_URL } from '@/lib/constants'
export const lintInfoMap: LintInfo[] = [
{
name: 'unindexed_foreign_keys',
title: 'Unindexed foreign keys',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/database/indexes?schema=${encodeURIComponent(metadata?.schema ?? '')}`,
linkText: 'Create an index',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0001_unindexed_foreign_keys`,
category: 'performance',
},
{
name: 'auth_users_exposed',
title: 'Exposed Auth Users',
icon: <Lock className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef }) => `/project/${projectRef}/editor`,
linkText: 'View table',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0002_auth_users_exposed`,
category: 'security',
},
{
name: 'auth_rls_initplan',
title: 'Auth RLS Initialization Plan',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/policies`,
linkText: 'View policies',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0003_auth_rls_initplan`,
category: 'performance',
},
{
name: 'no_primary_key',
title: 'No Primary Key',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/editor`,
linkText: 'View table',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0004_no_primary_key`,
category: 'performance',
},
{
name: 'unused_index',
title: 'Unused Index',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/database/indexes?schema=${encodeURIComponent(metadata?.schema ?? '')}&table=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View index',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0005_unused_index`,
category: 'performance',
},
{
name: 'multiple_permissive_policies',
title: 'Multiple Permissive Policies',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View policies',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0006_multiple_permissive_policies`,
category: 'performance',
},
{
name: 'policy_exists_rls_disabled',
title: 'Policy Exists RLS Disabled',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View policies',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0007_policy_exists_rls_disabled`,
category: 'security',
},
{
name: 'rls_enabled_no_policy',
title: 'RLS Enabled No Policy',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View table',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0008_rls_enabled_no_policy`,
category: 'security',
},
{
name: 'duplicate_index',
title: 'Duplicate Index',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/database/indexes?schema=${encodeURIComponent(metadata?.schema ?? '')}&table=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View index',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0009_duplicate_index`,
category: 'performance',
},
{
name: 'security_definer_view',
title: 'Security Definer View',
icon: <Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: () =>
`${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0010_security_definer_view`,
linkText: 'View docs',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0010_security_definer_view`,
category: 'security',
},
{
name: 'function_search_path_mutable',
title: 'Function Search Path Mutable',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/database/functions?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View functions',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0011_function_search_path_mutable`,
category: 'security',
},
{
name: 'auth_allow_anonymous_sign_ins',
title: 'Anonymous Sign-Ins Allowed',
icon: <User className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/providers`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0012_auth_allow_anonymous_sign_ins`,
category: 'security',
},
{
name: 'rls_disabled_in_public',
title: 'RLS Disabled in Public',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View policies',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0013_rls_disabled_in_public`,
category: 'security',
},
{
name: 'extension_in_public',
title: 'Extension in Public',
icon: <Unlock className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/database/extensions?filter=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View extension',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0014_extension_in_public`,
category: 'security',
},
{
name: 'auth_otp_long_expiry',
title: 'Auth OTP Long Expiry',
icon: <Clock className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/providers`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/platform/going-into-prod#security`,
category: 'security',
},
{
name: 'auth_otp_short_length',
title: 'Auth OTP Short Length',
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/providers`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/platform/going-into-prod#security`,
category: 'security',
},
{
name: 'auth_db_connections_absolute',
title: 'Auth Absolute Connection Management Strategy',
icon: <Scaling className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/performance`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/platform/going-into-prod`,
category: 'performance',
},
{
name: 'rls_references_user_metadata',
title: 'RLS references user metadata',
icon: <User className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/policies`,
linkText: 'View policies',
docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0015_rls_references_user_metadata`,
category: 'security',
},
{
name: 'materialized_view_in_api',
title: 'Materialized View in API',
icon: <Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: () => `${DOCS_URL}/guides/database/database-advisors?lint=0016_materialized_view_in_api`,
linkText: 'View docs',
docsLink: `${DOCS_URL}/guides/database/database-advisors?lint=0016_materialized_view_in_api`,
category: 'security',
},
{
name: 'foreign_table_in_api',
title: 'Foreign Table in API',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: () => `${DOCS_URL}/guides/database/database-linter?lint=0017_foreign_table_in_api`,
linkText: 'View docs',
docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0017_foreign_table_in_api`,
category: 'security',
},
{
name: 'unsupported_reg_types',
title: 'Unsupported reg types',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: () =>
`${DOCS_URL}/guides/database/database-advisors?lint=0018_unsupported_reg_types&queryGroups=lint`,
linkText: 'View docs',
docsLink: `${DOCS_URL}/guides/database/database-advisors?lint=0018_unsupported_reg_types&queryGroups=lint`,
category: 'security',
},
{
name: 'ssl_not_enforced',
title: 'SSL not enforced',
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/database/settings`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/platform/ssl-enforcement`,
category: 'security',
},
{
name: 'network_restrictions_not_set',
title: 'No network restrictions',
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/database/settings`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/platform/network-restrictions`,
category: 'security',
},
{
name: 'password_requirements_min_length',
title: 'Minimum password length not set or inadequate',
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/providers?provider=Email`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/platform/going-into-prod#security`,
category: 'security',
},
{
name: 'pitr_not_enabled',
title: 'PITR not enabled',
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/database/backups/pitr`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/platform/backups#point-in-time-recovery`,
category: 'security',
},
{
name: 'auth_leaked_password_protection',
title: 'Leaked Password Protection Disabled',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/providers?provider=Email`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/auth/password-security#password-strength-and-leaked-password-protection`,
category: 'security',
},
{
name: 'auth_insufficient_mfa_options',
title: 'Insufficient MFA Options',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/mfa`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/auth/auth-mfa`,
category: 'security',
},
{
name: 'auth_password_policy_missing',
title: 'Password Policy Missing',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/providers?provider=Email`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/auth/password-security`,
category: 'security',
},
{
name: 'leaked_service_key',
title: 'Leaked Service Key Detected',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/api-keys`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/api/api-keys#the-servicerole-key`,
category: 'security',
},
{
name: 'no_backup_admin',
title: 'No Backup Admin Detected',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/auth/mfa`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/auth/auth-mfa`,
category: 'security',
},
{
name: 'vulnerable_postgres_version',
title: 'Postgres version has security patches available',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/infrastructure`,
linkText: 'View settings',
docsLink: `${DOCS_URL}/guides/platform/upgrading`,
category: 'security',
},
{
name: 'sensitive_columns_exposed',
title: 'Sensitive Columns Exposed',
icon: <Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/editor?schema=${encodeURIComponent(metadata?.schema ?? '')}&table=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View table',
docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0023_sensitive_columns_exposed`,
category: 'security',
},
{
name: 'rls_policy_always_true',
title: 'RLS Policy Always True',
icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View policies',
docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0024_permissive_rls_policy`,
category: 'security',
},
{
name: 'public_bucket_allows_listing',
title: 'Public Bucket Allows Listing',
icon: <Box className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef, metadata }) => {
const bucketId = (metadata as Record<string, string | undefined> | undefined)?.bucket_id
return `/project/${projectRef}/storage/files/buckets/${encodeURIComponent(bucketId ?? metadata?.name ?? '')}`
},
linkText: 'View bucket',
docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0025_public_bucket_allows_listing`,
category: 'security',
},
{
name: 'pg_graphql_anon_table_exposed',
title: 'Public Can See Object in GraphQL Schema',
icon: <Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/editor?schema=${encodeURIComponent(metadata?.schema ?? '')}&table=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View object',
docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0026_pg_graphql_anon_table_exposed`,
category: 'security',
},
{
name: 'pg_graphql_authenticated_table_exposed',
title: 'Signed-In Users Can See Object in GraphQL Schema',
icon: <Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/editor?schema=${encodeURIComponent(metadata?.schema ?? '')}&table=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View object',
docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0027_pg_graphql_authenticated_table_exposed`,
category: 'security',
},
{
name: 'anon_security_definer_function_executable',
title: 'Public Can Execute SECURITY DEFINER Function',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/database/functions?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View function',
docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0028_anon_security_definer_function_executable`,
category: 'security',
},
{
name: 'authenticated_security_definer_function_executable',
title: 'Signed-In Users Can Execute SECURITY DEFINER Function',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef, metadata }) =>
`/project/${projectRef}/database/functions?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`,
linkText: 'View function',
docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0029_authenticated_security_definer_function_executable`,
category: 'security',
},
]
export const LintCTA = ({
title,
projectRef,
metadata,
onAfterAction,
}: {
title: LINT_TYPES
projectRef: string
metadata: Lint['metadata']
onAfterAction?: () => void
}) => {
const lintInfo = lintInfoMap.find((item) => item.name === title)
if (!lintInfo) {
return null
}
const graphqlExposureLintName = asGraphqlExposureLint(title)
if (graphqlExposureLintName) {
return (
<GraphqlExposureLintCTA
lintName={graphqlExposureLintName}
projectRef={projectRef}
metadata={metadata}
onAfterAction={onAfterAction}
/>
)
}
const link = lintInfo.link({ projectRef, metadata })
const linkText = lintInfo.linkText
return (
<Button asChild type="default">
<Link href={link} rel="noreferrer" className="no-underline">
{linkText}
</Link>
</Button>
)
}
export const EntityTypeIcon = ({ type }: { type: string | undefined }) => {
switch (type) {
case 'table':
return <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />
case 'view':
return <Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />
case 'auth':
return <Lock className="text-foreground-muted" size={15} strokeWidth={1.5} />
default:
return <Box className="text-foreground-muted" size={15} strokeWidth={1.5} />
}
}
export const LintEntity = ({ metadata }: { metadata: Lint['metadata'] }) => {
return getLintEntityString(metadata)
}
export const LintCategoryBadge = ({ category }: { category: string }) => {
return (
<Badge variant={category === 'SECURITY' ? 'destructive' : 'warning'}>
{category.toLowerCase()}
</Badge>
)
}
export const NoIssuesFound = ({ level }: { level: string }) => {
const noun = level === LINTER_LEVELS.ERROR ? 'errors' : 'warnings'
return (
<div className="absolute top-28 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<TextSearch className="text-foreground-muted" strokeWidth={1} />
<div className="text-center">
<p className="text-foreground">No {noun} detected</p>
<p className="text-foreground-light">
Congrats! There are no {noun} detected for this database
</p>
</div>
</div>
)
}
export const createLintSummaryPrompt = (lint: Lint) => {
const title = lintInfoMap.find((item) => item.name === lint.name)?.title ?? lint.title
const entity = getLintEntityString(lint.metadata) || 'N/A'
const schema = lint.metadata?.schema ?? 'N/A'
const issue = lint.detail ? lint.detail.replace(/\\`/g, '`') : 'N/A'
const description = lint.description ? lint.description.replace(/\\`/g, '`') : 'N/A'
return `Summarize the issue and suggest fixes for the following lint item:
Title: ${title}
Entity: ${entity}
Schema: ${schema}
Issue Details: ${issue}
Description: ${description}`
}
export const getLintEntityString = (metadata: Lint['metadata']) => {
if (!metadata) {
return undefined
}
if (metadata.entity) {
return metadata.entity
}
if (metadata.schema && metadata.name) {
const extendedMetadata = metadata as typeof metadata & { arguments?: string }
const args =
typeof extendedMetadata.arguments === 'string' ? extendedMetadata.arguments : undefined
return `${metadata.schema}.${metadata.name}${args !== undefined ? `(${args})` : ''}`
}
return undefined
}