From e193c94f06f9f83e93b4ae1f8a606235eb830fd1 Mon Sep 17 00:00:00 2001 From: Giancarlo Buomprisco Date: Tue, 22 Apr 2025 05:44:55 +0700 Subject: [PATCH] Cookies validation and Security Guidelines (#242) * Add OTP and security guidelines documentation and additional checks on client-provided values - Introduced additional checks on client-provided values such as cookies - Introduced a new OTP API documentation outlining the creation and verification of OTP tokens for sensitive operations. - Added comprehensive security guidelines for writing secure code in Next.js, covering client and server components, environment variables, authentication, and error handling. These additions enhance the project's security posture and provide clear instructions for developers on implementing secure practices. * Add OTP API documentation and enhance security guidelines - Introduced comprehensive documentation for the OTP API, detailing the creation and verification of OTP tokens for sensitive operations. - Enhanced security guidelines for Next.js, emphasizing the importance of input validation, environment variable management, and error handling. - Implemented additional checks for client-provided values to improve overall security posture. These updates provide clear instructions for developers and strengthen the project's security framework. --- .cursor/rules/otp.mdc | 59 ++++++++++ .cursor/rules/security.mdc | 152 +++++++++++++++++++++++++ apps/web/app/home/(user)/layout.tsx | 15 ++- apps/web/app/home/[account]/layout.tsx | 22 ++-- apps/web/lib/i18n/i18n.resolver.ts | 32 +++++- apps/web/lib/i18n/i18n.server.ts | 40 +++++-- apps/web/lib/root-theme.ts | 39 ++++++- 7 files changed, 325 insertions(+), 34 deletions(-) create mode 100644 .cursor/rules/otp.mdc create mode 100644 .cursor/rules/security.mdc diff --git a/.cursor/rules/otp.mdc b/.cursor/rules/otp.mdc new file mode 100644 index 000000000..da45e57de --- /dev/null +++ b/.cursor/rules/otp.mdc @@ -0,0 +1,59 @@ +--- +description: The OTP API provides the ability to perform additional checks before executing sensitive operations +globs: +alwaysApply: false +--- +The OTP API allows the user to: + +1. protect sensitive operations behind an additional layer of verification +2. other security operations such as oAuth2 (storing the "state" parameter with additional metadata) + +- API: The OTP API [index.ts](mdc:packages/otp/src/api/index.ts) abstract operations with the Database RPCs. +- The Database schema can be found at [12-one-time-tokens.sql](mdc:apps/web/supabase/schemas/12-one-time-tokens.sql) + +## Creating an OTP Token +We can se the [verify-otp-form.tsx](mdc:packages/otp/src/components/verify-otp-form.tsx) for creating a quick form to create tokens server side. + +```tsx +import { VerifyOtpForm } from '@kit/otp/components'; + +function MyVerificationPage(props: { + userEmail: string; +}) { + return ( + { + // Handle successful verification + // Use the OTP for verification on the server + }} + CancelButton={ + + } + /> + ); +} +``` + +## Verifying a Token +And here is the server action that verifies the OTP: + +```tsx +// Verify the token +const result = await api.verifyToken({ + token: submittedToken, + purpose: 'email-verification' +}); + +if (result.valid) { + // Token is valid, proceed with the operation + const { userId, metadata } = result; + // Handle successful verification +} else { + // Token is invalid or expired + // Handle verification failure +} +``` \ No newline at end of file diff --git a/.cursor/rules/security.mdc b/.cursor/rules/security.mdc new file mode 100644 index 000000000..93d769b7d --- /dev/null +++ b/.cursor/rules/security.mdc @@ -0,0 +1,152 @@ +--- +description: Security guidelines for writing secure code +globs: +alwaysApply: false +--- +# Next.js-Specific Security + +### Client Component Data Passing + +- **Never pass sensitive data** to Client Components +- **Never pass unsanitized data** to Client Components (raw cookies, client-provided data) + +### Server Components Security + +- **Always sanitize user input** before using in Server Components +- **Validate cookies and headers** in Server Components + +### Environment Variables + +- **Use `import 'server-only'`** for code that should only be run on the server side +- **Never expose server-only env vars** to the client +- **Never pass environment variables as props to client components** unless they're suffixed with `NEXT_PUBLIC_` +- **Never use `NEXT_PUBLIC_` prefix** for sensitive data (ex. API keys, secrets) +- **Use `NEXT_PUBLIC_` prefix** only for client-safe variables + +### Client Hydration Protection + +- **Never expose sensitive data** in initial HTML + +## Authentication & Authorization + +### Row Level Security (RLS) + +- **Always enable RLS** on all tables unless explicitly specified otherwise [database.mdc](mdc:.cursor/rules/database.mdc) + +### Super Admin Protected Routes +Always perform extra checks when writing Super Admin code [super-admin.mdc](mdc:.cursor/rules/super-admin.mdc) + +## Server Actions & API Routes + +### Server Actions + +- Always use `enhanceAction` wrapper for consistent security [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) +- Always use 'use server' directive at the top of the file to safely bundle server-side code +- Validate input with Zod schemas +- Implement authentication checks: + +```typescript +'use server'; + +import { enhanceAction } from '@kit/next/actions'; +import { MyActionSchema } from '../schema'; + +export const secureAction = enhanceAction( + async function(data, user) { + // Additional permission checks + const hasPermission = await checkUserPermission(user.id, data.accountId, 'action.perform'); + if (!hasPermission) throw new Error('Insufficient permissions'); + + // Validated data available + return processAction(data); + }, + { + auth: true, + schema: MyActionSchema + } +); +``` + +### API Routes + +- Use `enhanceRouteHandler` for consistent security [route-handlers.mdc](mdc:.cursor/rules/route-handlers.mdc) +- Implement authentication and authorization checks: + +```typescript +import { enhanceRouteHandler } from '@kit/next/routes'; +import { RouteSchema } from '../schema'; + +export const POST = enhanceRouteHandler( + async function({ body, user, request }) { + // Additional authorization checks + const canAccess = await canAccessResource(user.id, body.resourceId); + + if (!canAccess) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + // Safe to process with validated body + return NextResponse.json({ success: true }); + }, + { + auth: true, + schema: RouteSchema + } +); +``` + +## Client Components Security + +### Context Awareness + +- Use appropriate workspace contexts for access control: + - `useUserWorkspace()` in personal account pages + - `useTeamAccountWorkspace()` in team account pages +- Check permissions before rendering sensitive UI elements: + +```typescript +function SecureComponent() { + const { account, user } = useTeamAccountWorkspace(); + const canEdit = account.permissions.includes('entity.update'); + + if (!canEdit) return null; + + return ; +} +``` + +### Data Fetching + +- Use React Query with proper error handling +- Never trust client-side permission checks alone + +## One-Time Tokens + +Consider using OTP tokens when implementing highly destructive operations like deleting an entity that would otherwise require a full backup: [otp.mdc](mdc:.cursor/rules/otp.mdc) + +## Critical Data Protection + +### Personal Information + +- Never log or expose sensitive user data +- Use proper session management + +## Error Handling + +- Never expose internal errors to clients +- Log errors securely with appropriate context +- Return generic error messages to users + +```typescript +try { + await sensitiveOperation(); +} catch (error) { + logger.error({ error, context }, 'Operation failed'); + return { error: 'Unable to complete operation' }; +} +``` + +## Database Security + +- Avoid dynamic SQL generation +- Use SECURITY DEFINER functions sparingly and carefully, warn user if you do so +- Implement proper foreign key constraints +- Use appropriate data types with constraints \ No newline at end of file diff --git a/apps/web/app/home/(user)/layout.tsx b/apps/web/app/home/(user)/layout.tsx index 26b423d1a..6ede44705 100644 --- a/apps/web/app/home/(user)/layout.tsx +++ b/apps/web/app/home/(user)/layout.tsx @@ -2,6 +2,8 @@ import { use } from 'react'; import { cookies } from 'next/headers'; +import { z } from 'zod'; + import { UserWorkspaceContextProvider } from '@kit/accounts/components'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; @@ -88,18 +90,23 @@ function MobileNavigation({ async function getLayoutState() { const cookieStore = await cookies(); + const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']); + const layoutStyleCookie = cookieStore.get('layout-style'); const sidebarOpenCookie = cookieStore.get('sidebar:state'); - const sidebarOpenCookieValue = sidebarOpenCookie + const sidebarOpen = sidebarOpenCookie ? sidebarOpenCookie.value === 'false' : !personalAccountNavigationConfig.sidebarCollapsed; - const style = - layoutStyleCookie?.value ?? personalAccountNavigationConfig.style; + const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value); + + const style = parsedStyle.success + ? parsedStyle.data + : personalAccountNavigationConfig.style; return { - open: sidebarOpenCookieValue, + open: sidebarOpen, style, }; } diff --git a/apps/web/app/home/[account]/layout.tsx b/apps/web/app/home/[account]/layout.tsx index 1bd21ef5e..ffefb5f7c 100644 --- a/apps/web/app/home/[account]/layout.tsx +++ b/apps/web/app/home/[account]/layout.tsx @@ -2,13 +2,10 @@ import { use } from 'react'; import { cookies } from 'next/headers'; +import { z } from 'zod'; + import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components'; -import { - Page, - PageLayoutStyle, - PageMobileNavigation, - PageNavigation, -} from '@kit/ui/page'; +import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { AppLogo } from '~/components/app-logo'; @@ -124,19 +121,26 @@ function HeaderLayout({ async function getLayoutState(account: string) { const cookieStore = await cookies(); + const config = getTeamAccountSidebarConfig(account); + + const LayoutStyleSchema = z + .enum(['sidebar', 'header', 'custom']) + .default(config.style); + const sidebarOpenCookie = cookieStore.get('sidebar:state'); const layoutCookie = cookieStore.get('layout-style'); - const layoutStyle = layoutCookie?.value as PageLayoutStyle; - const config = getTeamAccountSidebarConfig(account); + const layoutStyle = LayoutStyleSchema.safeParse(layoutCookie?.value); const sidebarOpenCookieValue = sidebarOpenCookie ? sidebarOpenCookie.value === 'false' : !config.sidebarCollapsed; + const style = layoutStyle.success ? layoutStyle.data : config.style; + return { open: sidebarOpenCookieValue, - style: layoutStyle ?? config.style, + style, }; } diff --git a/apps/web/lib/i18n/i18n.resolver.ts b/apps/web/lib/i18n/i18n.resolver.ts index 96f1c7d49..46d70fe2d 100644 --- a/apps/web/lib/i18n/i18n.resolver.ts +++ b/apps/web/lib/i18n/i18n.resolver.ts @@ -1,11 +1,31 @@ +import { getLogger } from '@kit/shared/logger'; + /** - * Resolves the translation file for a given language and namespace. - * + * @name i18nResolver + * @description Resolve the translation file for the given language and namespace in the current application. + * @param language + * @param namespace */ export async function i18nResolver(language: string, namespace: string) { - const data = await import( - `../../public/locales/${language}/${namespace}.json` - ); + const logger = await getLogger(); - return data as Record; + try { + const data = await import( + `../../public/locales/${language}/${namespace}.json` + ); + + return data as Record; + } catch (error) { + console.group( + `Error while loading translation file: ${language}/${namespace}`, + ); + logger.error(error instanceof Error ? error.message : error); + logger.warn( + `Please create a translation file for this language at "public/locales/${language}/${namespace}.json"`, + ); + console.groupEnd(); + + // return an empty object if the file could not be loaded to avoid loops + return {}; + } } diff --git a/apps/web/lib/i18n/i18n.server.ts b/apps/web/lib/i18n/i18n.server.ts index 1ee774363..9074d2b02 100644 --- a/apps/web/lib/i18n/i18n.server.ts +++ b/apps/web/lib/i18n/i18n.server.ts @@ -1,7 +1,11 @@ +import 'server-only'; + import { cache } from 'react'; import { cookies, headers } from 'next/headers'; +import { z } from 'zod'; + import { initializeServerI18n, parseAcceptLanguageHeader, @@ -32,13 +36,13 @@ const priority = featuresFlagConfig.languagePriority; */ async function createInstance() { const cookieStore = await cookies(); - const cookie = cookieStore.get(I18N_COOKIE_NAME)?.value; + const langCookieValue = cookieStore.get(I18N_COOKIE_NAME)?.value; let selectedLanguage: string | undefined = undefined; // if the cookie is set, use the language from the cookie - if (cookie) { - selectedLanguage = getLanguageOrFallback(cookie); + if (langCookieValue) { + selectedLanguage = getLanguageOrFallback(langCookieValue); } // if not, check if the language priority is set to user and @@ -56,10 +60,15 @@ async function createInstance() { export const createI18nServerInstance = cache(createInstance); +/** + * @name getPreferredLanguageFromBrowser + * Get the user's preferred language from the accept-language header. + */ async function getPreferredLanguageFromBrowser() { const headersStore = await headers(); const acceptLanguage = headersStore.get('accept-language'); + // no accept-language header, return if (!acceptLanguage) { return; } @@ -67,16 +76,23 @@ async function getPreferredLanguageFromBrowser() { return parseAcceptLanguageHeader(acceptLanguage, languages)[0]; } -function getLanguageOrFallback(language: string | undefined) { - let selectedLanguage = language; +/** + * @name getLanguageOrFallback + * Get the language or fallback to the default language. + * @param selectedLanguage + */ +function getLanguageOrFallback(selectedLanguage: string | undefined) { + const language = z + .enum(languages as [string, ...string[]]) + .safeParse(selectedLanguage); - if (!languages.includes(language ?? '')) { - console.warn( - `Language "${language}" is not supported. Falling back to "${languages[0]}"`, - ); - - selectedLanguage = languages[0]; + if (language.success) { + return language.data; } - return selectedLanguage; + console.warn( + `The language passed is invalid. Defaulted back to "${languages[0]}"`, + ); + + return languages[0]; } diff --git a/apps/web/lib/root-theme.ts b/apps/web/lib/root-theme.ts index f45eca8f6..a7aba8bfa 100644 --- a/apps/web/lib/root-theme.ts +++ b/apps/web/lib/root-theme.ts @@ -1,6 +1,28 @@ import { cookies } from 'next/headers'; -type Theme = 'light' | 'dark' | 'system'; +import { z } from 'zod'; + +/** + * @name Theme + * @description The theme mode enum. + */ +const Theme = z.enum(['light', 'dark', 'system'], { + description: 'The theme mode', +}); + +/** + * @name appDefaultThemeMode + * @description The default theme mode set by the application. + */ +const appDefaultThemeMode = Theme.safeParse( + process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE, +); + +/** + * @name fallbackThemeMode + * @description The fallback theme mode if none of the other options are available. + */ +const fallbackThemeMode = `light`; /** * @name getRootTheme @@ -9,8 +31,19 @@ type Theme = 'light' | 'dark' | 'system'; */ export async function getRootTheme() { const cookiesStore = await cookies(); + const themeCookieValue = cookiesStore.get('theme')?.value; + const theme = Theme.safeParse(themeCookieValue); - const themeCookie = cookiesStore.get('theme')?.value as Theme; + // pass the theme from the cookie if it exists + if (theme.success) { + return theme.data; + } - return themeCookie ?? process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE ?? 'light'; + // pass the default theme from the environment variable if it exists + if (appDefaultThemeMode.success) { + return appDefaultThemeMode.data; + } + + // in all other cases, fallback to the default theme + return fallbackThemeMode; }