mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
[FE-3379] feat(studio): warn when db passwords need percent-encoding (#47564)
Users who set a database password with special characters (\`@\`, \`#\`, \`%\`, \`+\`, etc.) get no warning that it must be percent-encoded when used in a connection URL, which leads to confusing connection failures ([FE-3379](https://linear.app/supabase/issue/FE-3379)). <img width="700" height="200" alt="Screenshot 2026-07-03 at 6 26 43 PM" src="https://github.com/user-attachments/assets/48608d65-8057-4abe-96fc-c0ede3550951" /> <img width="1002" height="395" alt="Screenshot 2026-07-03 at 6 27 14 PM" src="https://github.com/user-attachments/assets/1366b985-7d80-4e7d-97f0-c79d5c84cefd" /> <img width="548" height="303" alt="Screenshot 2026-07-03 at 6 27 26 PM" src="https://github.com/user-attachments/assets/b042101a-0e88-4730-adb8-1b490018f208" /> **Changed:** - `PasswordStrengthBar` now shows a warning-colored callout (with a docs link) whenever the entered password contains characters that need percent-encoding — this covers project creation, reset database password, restore-to-new-project, and the Vercel deploy-button flow - Replaced `DATABASE_PASSWORD_REGEX` (only caught `@`, `:`, `/`) with a `passwordNeedsPercentEncoding()` helper based on `encodeURIComponent`, so `#`, `%`, `+`, `?`, `&`, spaces etc. are caught too - Moved `SpecialSymbolsCallout` from `ProjectCreation/` to `components/ui/` since it's now shared **Added:** - Info admonition in the Connect sheet next to connection strings that still contain `[YOUR-PASSWORD]` (direct connection + `.env`-based file setups; hidden for psql and .NET where percent-encoding doesn't apply, and after a password reset since the substituted password is already encoded) ## To test - Project creation → type a password containing \`#\` or \`@\` → warning callout appears above the strength bar; disappears for alphanumeric passwords - Database Settings → Reset database password → same behaviour - Connect sheet → Direct connection → note shows under the connection string for URI/JDBC types, not for psql; after resetting the password from the sheet, the note disappears (password is substituted already encoded) - Connect sheet → Node.js/Python/Go/SQLAlchemy file setups show the note; .NET does not - \`pnpm vitest run lib/password-strength.test.ts\` passes <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added a dedicated password encoding note (with documentation link) on direct connection screens when the password is embedded in a URL. * Added an encoding hint to the password strength area when percent-encoding is required. * **Bug Fixes** * Removed regex-based “invalid password” callout and replaced it with safer percent-encoding detection logic. * **Tests** * Added test coverage for `passwordNeedsPercentEncoding`. * Removed obsolete Project Creation password regex tests. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
1 parent
0a9a0eec92
commit
46b31eb53a
12 files changed
+86
-43
No files matched your search
@@ -0,0 +1,19 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export const PasswordEncodingNote = () => {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
description={
|
||||
<>
|
||||
If your database password contains special characters, you will need to{' '}
|
||||
<InlineLink href={SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL}>percent-encode</InlineLink> them
|
||||
in the connection string.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+2
@@ -26,6 +26,7 @@ import {
|
||||
PASSWORD_PLACEHOLDER,
|
||||
resolveConnectionString,
|
||||
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
|
||||
import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote'
|
||||
import { ResetDbPasswordDialog } from '@/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog'
|
||||
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
|
||||
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
|
||||
@@ -237,6 +238,7 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) {
|
||||
<Badge>{poolerBadge}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{connectionString.includes(PASSWORD_PLACEHOLDER) && <PasswordEncodingNote />}
|
||||
<div className="overflow-hidden rounded-lg border bg-surface-75">
|
||||
<div data-connect-copy-value={redactedConnectionString}>
|
||||
<CodeBlock
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PASSWORD_PLACEHOLDER,
|
||||
resolveConnectionString,
|
||||
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
|
||||
import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote'
|
||||
|
||||
const DOTNET_CONFIG_COMMAND =
|
||||
'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION'
|
||||
@@ -27,6 +28,8 @@ type DirectFilesConfig = {
|
||||
code: string
|
||||
}[]
|
||||
connectionStringFile?: string
|
||||
/** The password ends up inside a connection URL, so special characters must be percent-encoded */
|
||||
passwordInUrl?: boolean
|
||||
postCommands?: { label: string; command: string }[]
|
||||
}
|
||||
|
||||
@@ -79,6 +82,7 @@ export default sql`,
|
||||
envFile,
|
||||
],
|
||||
connectionStringFile: envFile.name,
|
||||
passwordInUrl: true,
|
||||
}
|
||||
|
||||
case 'golang':
|
||||
@@ -115,6 +119,7 @@ func main() {
|
||||
envFile,
|
||||
],
|
||||
connectionStringFile: envFile.name,
|
||||
passwordInUrl: true,
|
||||
}
|
||||
|
||||
case 'dotnet':
|
||||
@@ -161,6 +166,7 @@ connection = psycopg2.connect(DATABASE_URL)`,
|
||||
envFile,
|
||||
],
|
||||
connectionStringFile: envFile.name,
|
||||
passwordInUrl: true,
|
||||
}
|
||||
|
||||
case 'sqlalchemy':
|
||||
@@ -213,6 +219,7 @@ except Exception as e:
|
||||
},
|
||||
],
|
||||
connectionStringFile: '.env',
|
||||
passwordInUrl: true,
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -242,6 +249,7 @@ except Exception as e:
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<MultipleCodeBlock files={config.files} value={activeFile} onValueChange={setActiveFile} />
|
||||
{config.passwordInUrl && <PasswordEncodingNote />}
|
||||
<ConnectionParameters parameters={buildConnectionParameters(connectionParams)} />
|
||||
{(config.postCommands ?? []).map((command) => (
|
||||
<div key={command.command} className="flex flex-col gap-2">
|
||||
|
||||
@@ -3,9 +3,7 @@ import { FormControl, FormField } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { DATABASE_PASSWORD_REGEX } from './ProjectCreation.constants'
|
||||
import { CreateProjectForm } from './ProjectCreation.schema'
|
||||
import { SpecialSymbolsCallout } from './SpecialSymbolsCallout'
|
||||
import Panel from '@/components/ui/Panel'
|
||||
import { PasswordStrengthBar } from '@/components/ui/PasswordStrengthBar'
|
||||
import { passwordStrength } from '@/lib/password-strength'
|
||||
@@ -49,23 +47,17 @@ export const DatabasePasswordInput = ({ form }: DatabasePasswordInputProps) => {
|
||||
control={form.control}
|
||||
name="dbPass"
|
||||
render={({ field }) => {
|
||||
const isInvalidDatabasePassword =
|
||||
field.value.length > 0 && !field.value.match(DATABASE_PASSWORD_REGEX)
|
||||
|
||||
return (
|
||||
<FormItemLayout
|
||||
label="Database password"
|
||||
layout="horizontal"
|
||||
description={
|
||||
<>
|
||||
{isInvalidDatabasePassword && <SpecialSymbolsCallout />}
|
||||
<PasswordStrengthBar
|
||||
passwordStrengthScore={form.getValues('dbPassStrength')}
|
||||
password={field.value}
|
||||
passwordStrengthMessage={form.getValues('dbPassStrengthMessage')}
|
||||
generateStrongPassword={generatePassword}
|
||||
/>
|
||||
</>
|
||||
<PasswordStrengthBar
|
||||
passwordStrengthScore={form.getValues('dbPassStrength')}
|
||||
password={field.value}
|
||||
passwordStrengthMessage={form.getValues('dbPassStrengthMessage')}
|
||||
generateStrongPassword={generatePassword}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
|
||||
@@ -255,6 +255,4 @@ export const COUNTRY_LAT_LON = {
|
||||
ZW: { lat: -20, lon: 30 },
|
||||
}
|
||||
|
||||
export const DATABASE_PASSWORD_REGEX = /^[^@:\/]*$/
|
||||
|
||||
export const sizes: DesiredInstanceSize[] = ['micro', 'small', 'medium']
|
||||
@@ -1,11 +0,0 @@
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import { DATABASE_PASSWORD_REGEX } from './ProjectCreation.constants'
|
||||
|
||||
test('Regex test to surface if password contains @, : or /', () => {
|
||||
expect(!'teststring'.match(DATABASE_PASSWORD_REGEX)).toEqual(false)
|
||||
expect(!'test@string'.match(DATABASE_PASSWORD_REGEX)).toEqual(true)
|
||||
expect(!'te:ststring'.match(DATABASE_PASSWORD_REGEX)).toEqual(true)
|
||||
expect(!`tests/tring`.match(DATABASE_PASSWORD_REGEX)).toEqual(true)
|
||||
expect(!'!#$%^&*()'.match(DATABASE_PASSWORD_REGEX)).toEqual(false)
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export const SpecialSymbolsCallout = () => {
|
||||
return (
|
||||
<p className="mb-2">
|
||||
Note: If using the Postgres connection string, you will need to{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/database/postgres/roles#special-symbols-in-passwords`}>
|
||||
percent-encode
|
||||
</InlineLink>{' '}
|
||||
the password
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { InlineLinkClassName } from './InlineLink'
|
||||
import { SpecialSymbolsCallout } from './SpecialSymbolsCallout'
|
||||
import { PASSWORD_STRENGTH_COLOR, PASSWORD_STRENGTH_PERCENTAGE } from '@/lib/constants'
|
||||
import { PasswordStrengthScore } from '@/lib/password-strength'
|
||||
import { passwordNeedsPercentEncoding, PasswordStrengthScore } from '@/lib/password-strength'
|
||||
|
||||
interface Props {
|
||||
passwordStrengthScore: PasswordStrengthScore
|
||||
@@ -17,6 +18,7 @@ export const PasswordStrengthBar = ({
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
{passwordNeedsPercentEncoding(password) && <SpecialSymbolsCallout />}
|
||||
{password && (
|
||||
<div
|
||||
aria-valuemax={100}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export const SpecialSymbolsCallout = () => {
|
||||
return (
|
||||
<p className="mb-2 text-warning">
|
||||
Note: If using the Postgres connection string, you will need to{' '}
|
||||
<InlineLink href={SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL}>percent-encode</InlineLink> the
|
||||
password
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export const POSTHOG_URL =
|
||||
export const USAGE_APPROACHING_THRESHOLD = 0.75
|
||||
|
||||
export const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL || 'https://supabase.com/docs'
|
||||
export const SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL = `${DOCS_URL}/guides/database/postgres/roles#special-symbols-in-passwords`
|
||||
|
||||
export const OPT_IN_TAGS = {
|
||||
AI_SQL: 'AI_SQL_GENERATOR_OPT_IN',
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { passwordStrength } from './password-strength'
|
||||
import { passwordNeedsPercentEncoding, passwordStrength } from './password-strength'
|
||||
|
||||
describe('passwordNeedsPercentEncoding', () => {
|
||||
it('returns false for passwords that are safe to use in a connection string', () => {
|
||||
expect(passwordNeedsPercentEncoding('')).toBe(false)
|
||||
expect(passwordNeedsPercentEncoding('teststring')).toBe(false)
|
||||
expect(passwordNeedsPercentEncoding('Str0ngPassword123')).toBe(false)
|
||||
expect(passwordNeedsPercentEncoding('with-safe_chars.~!')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for passwords with characters that need percent-encoding', () => {
|
||||
expect(passwordNeedsPercentEncoding('test@string')).toBe(true)
|
||||
expect(passwordNeedsPercentEncoding('te:ststring')).toBe(true)
|
||||
expect(passwordNeedsPercentEncoding('tests/tring')).toBe(true)
|
||||
expect(passwordNeedsPercentEncoding('test#string')).toBe(true)
|
||||
expect(passwordNeedsPercentEncoding('test%string')).toBe(true)
|
||||
expect(passwordNeedsPercentEncoding('test+string')).toBe(true)
|
||||
expect(passwordNeedsPercentEncoding('test?string')).toBe(true)
|
||||
expect(passwordNeedsPercentEncoding('test&string')).toBe(true)
|
||||
expect(passwordNeedsPercentEncoding('test string')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('passwordStrength', () => {
|
||||
it('returns empty values for message, warning and strength for empty input', async () => {
|
||||
|
||||
@@ -4,6 +4,19 @@ import { DEFAULT_MINIMUM_PASSWORD_STRENGTH, PASSWORD_STRENGTH } from '@/lib/cons
|
||||
// but we need to define it here because we don't to import zxcvbn everywhere
|
||||
export type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
/**
|
||||
* True when the password contains characters that must be percent-encoded
|
||||
* before the password can be used in a connection string URL.
|
||||
*/
|
||||
export function passwordNeedsPercentEncoding(password: string) {
|
||||
try {
|
||||
return password !== encodeURIComponent(password)
|
||||
} catch {
|
||||
// encodeURIComponent throws on lone surrogates
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export async function passwordStrength(value: string) {
|
||||
// [Alaister]: Lazy load zxcvbn to avoid bundling it with the main app (it's pretty chunky)
|
||||
const zxcvbn = await import('zxcvbn').then((module) => module.default)
|
||||
|
||||
Reference in new issue
Block a user