From 29bfa7b75bd5f1209717efdc3f08b2a84764f982 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 14 May 2026 22:52:49 +0400 Subject: [PATCH] fix(studio): encode special characters in project securing policies links (#45849) Closes #45847. ## Summary `ProjectNeedsSecuringView.tsx` built the `View policies` href on the first-time security gate by interpolating `table.schema` and `table.name` directly into the URL. A table or schema containing `&`, `=`, `+`, or `#` corrupted the destination and routed the user to the wrong policies filter on what is meant to be a guided onboarding flow. Extracts the URL into `getTablePoliciesHref` in `ProjectNeedsSecuring.utils.ts` with `encodeURIComponent` wraps, and replaces the inline interpolation. Same pattern as #45385. ## Test plan Added `ProjectNeedsSecuring.utils.test.ts` covering `getTablePoliciesHref` (plain values, special chars in name, special chars in schema, both, undefined inputs) and pulling in the previously-untested `getTableKey`, `formatRlsDescription`, `sortTables`, and `buildSecurityPromptMarkdown` utilities. Ten tests total. ## Summary by CodeRabbit * **Tests** * Added comprehensive test coverage for security utilities, including URL construction, formatting, sorting, and markdown report generation. * **Refactor** * Extracted URL building logic into a centralized utility function for improved consistency and maintainability. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45849) --- .../ProjectNeedsSecuring.utils.test.ts | 96 +++++++++++++++++++ .../ProjectNeedsSecuring.utils.ts | 10 ++ .../ProjectNeedsSecuringView.tsx | 7 +- 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.test.ts diff --git a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.test.ts b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.test.ts new file mode 100644 index 00000000000..42d8dc33170 --- /dev/null +++ b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' + +import type { ProjectSecurityTable } from './ProjectNeedsSecuring.types' +import { + buildSecurityPromptMarkdown, + formatRlsDescription, + getTableKey, + getTablePoliciesHref, + sortTables, +} from './ProjectNeedsSecuring.utils' + +const table = (overrides: Partial): ProjectSecurityTable => ({ + id: 1, + name: 't', + schema: 'public', + rlsEnabled: false, + hasRlsIssue: false, + dataApiAccessible: false, + ...overrides, +}) + +describe('ProjectNeedsSecuring.utils: getTableKey', () => { + it('joins schema and name with a dot', () => { + expect(getTableKey({ schema: 'public', name: 'users' })).toBe('public.users') + }) +}) + +describe('ProjectNeedsSecuring.utils: formatRlsDescription', () => { + it('returns the singular form when count is 1', () => { + expect(formatRlsDescription(1)).toContain('1 table has RLS disabled') + expect(formatRlsDescription(1)).toContain('its data') + }) + + it('returns the plural form for any other count', () => { + expect(formatRlsDescription(3)).toContain('3 tables have RLS disabled') + expect(formatRlsDescription(3)).toContain('their data') + }) +}) + +describe('ProjectNeedsSecuring.utils: sortTables', () => { + it('puts tables with active RLS issues first', () => { + const tables = [ + table({ id: 1, name: 'a', hasRlsIssue: false, rlsEnabled: true }), + table({ id: 2, name: 'b', hasRlsIssue: true, rlsEnabled: false }), + table({ id: 3, name: 'c', hasRlsIssue: false, rlsEnabled: false }), + ] + const sorted = sortTables(tables) + expect(sorted.map((t) => t.name)).toEqual(['b', 'c', 'a']) + }) +}) + +describe('ProjectNeedsSecuring.utils: buildSecurityPromptMarkdown', () => { + it('builds a markdown report with a header row and one row per table', () => { + const markdown = buildSecurityPromptMarkdown(1, [ + table({ name: 'invoices', schema: 'public', dataApiAccessible: true, rlsEnabled: false }), + ]) + expect(markdown).toContain('## Project security review') + expect(markdown).toContain('1 table has RLS disabled') + expect(markdown).toContain('| invoices | public | Yes | Disabled |') + }) +}) + +describe('ProjectNeedsSecuring.utils: getTablePoliciesHref', () => { + it('builds the policies href with plain values', () => { + expect(getTablePoliciesHref('abc', 'public', 'invoices')).toBe( + '/project/abc/auth/policies?schema=public&search=invoices' + ) + }) + + it('preserves special characters in the table name', () => { + const href = getTablePoliciesHref('abc', 'public', 'user_data&secret=1') + const parsed = new URL(href, 'http://example.com') + expect(parsed.searchParams.get('search')).toBe('user_data&secret=1') + expect(parsed.searchParams.get('schema')).toBe('public') + }) + + it('preserves special characters in the schema', () => { + const href = getTablePoliciesHref('abc', 'my schema+x', 'users') + const parsed = new URL(href, 'http://example.com') + expect(parsed.searchParams.get('schema')).toBe('my schema+x') + expect(parsed.searchParams.get('search')).toBe('users') + }) + + it('encodes both values together', () => { + const href = getTablePoliciesHref('abc', 'a&b=c', 'd e+f') + const parsed = new URL(href, 'http://example.com') + expect(parsed.searchParams.get('schema')).toBe('a&b=c') + expect(parsed.searchParams.get('search')).toBe('d e+f') + }) + + it('falls back to empty strings for undefined inputs', () => { + expect(getTablePoliciesHref(undefined, undefined, undefined)).toBe( + '/project//auth/policies?schema=&search=' + ) + }) +}) diff --git a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.ts b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.ts index 3fe11ae956e..86980be37e3 100644 --- a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.ts +++ b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.ts @@ -6,6 +6,16 @@ const DEFAULT_EXPOSED_SCHEMA = 'public' export const getTableKey = ({ schema, name }: { schema: string; name: string }) => `${schema}.${name}` +export const getTablePoliciesHref = ( + projectRef: string | undefined, + schema: string | undefined, + name: string | undefined +): string => { + return `/project/${projectRef ?? ''}/auth/policies?schema=${encodeURIComponent( + schema ?? '' + )}&search=${encodeURIComponent(name ?? '')}` +} + export const getExposedSchemas = (dbSchema: string | null | undefined) => { const schemas = dbSchema ? parseDbSchemaString(dbSchema) : [] return schemas.length > 0 ? schemas : [DEFAULT_EXPOSED_SCHEMA] diff --git a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuringView.tsx b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuringView.tsx index 2b45acc614b..d2788495044 100644 --- a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuringView.tsx +++ b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuringView.tsx @@ -42,6 +42,7 @@ import { buildSecurityPromptMarkdown, formatRlsDescription, getTableKey, + getTablePoliciesHref, } from './ProjectNeedsSecuring.utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' @@ -179,7 +180,11 @@ export const ProjectNeedsSecuringView = ({ {tables.map((table) => { - const policiesHref = `/project/${projectRef}/auth/policies?schema=${table.schema}&search=${table.name}` + const policiesHref = getTablePoliciesHref( + projectRef, + table.schema, + table.name + ) const handleNavigation = createNavigationHandler(policiesHref, router) const trackViewPolicies = () => onTrackAction('view_policies', {