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.
This commit is contained in:
Giancarlo Buomprisco
2025-04-22 05:44:55 +07:00
committed by GitHub
parent 1327a8efb7
commit e193c94f06
7 changed files with 325 additions and 34 deletions

59
.cursor/rules/otp.mdc Normal file
View File

@@ -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 (
<VerifyOtpForm
purpose="password-reset"
email={props.userEmail}
onSuccess={(otp) => {
// Handle successful verification
// Use the OTP for verification on the server
}}
CancelButton={
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
}
/>
);
}
```
## 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
}
```

152
.cursor/rules/security.mdc Normal file
View File

@@ -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 <EditComponent />;
}
```
### 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

View File

@@ -2,6 +2,8 @@ import { use } from 'react';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { z } from 'zod';
import { UserWorkspaceContextProvider } from '@kit/accounts/components'; import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
@@ -88,18 +90,23 @@ function MobileNavigation({
async function getLayoutState() { async function getLayoutState() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
const layoutStyleCookie = cookieStore.get('layout-style'); const layoutStyleCookie = cookieStore.get('layout-style');
const sidebarOpenCookie = cookieStore.get('sidebar:state'); const sidebarOpenCookie = cookieStore.get('sidebar:state');
const sidebarOpenCookieValue = sidebarOpenCookie const sidebarOpen = sidebarOpenCookie
? sidebarOpenCookie.value === 'false' ? sidebarOpenCookie.value === 'false'
: !personalAccountNavigationConfig.sidebarCollapsed; : !personalAccountNavigationConfig.sidebarCollapsed;
const style = const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
layoutStyleCookie?.value ?? personalAccountNavigationConfig.style;
const style = parsedStyle.success
? parsedStyle.data
: personalAccountNavigationConfig.style;
return { return {
open: sidebarOpenCookieValue, open: sidebarOpen,
style, style,
}; };
} }

View File

@@ -2,13 +2,10 @@ import { use } from 'react';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { z } from 'zod';
import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components'; import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components';
import { import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
Page,
PageLayoutStyle,
PageMobileNavigation,
PageNavigation,
} from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { AppLogo } from '~/components/app-logo'; import { AppLogo } from '~/components/app-logo';
@@ -124,19 +121,26 @@ function HeaderLayout({
async function getLayoutState(account: string) { async function getLayoutState(account: string) {
const cookieStore = await cookies(); 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 sidebarOpenCookie = cookieStore.get('sidebar:state');
const layoutCookie = cookieStore.get('layout-style'); const layoutCookie = cookieStore.get('layout-style');
const layoutStyle = layoutCookie?.value as PageLayoutStyle; const layoutStyle = LayoutStyleSchema.safeParse(layoutCookie?.value);
const config = getTeamAccountSidebarConfig(account);
const sidebarOpenCookieValue = sidebarOpenCookie const sidebarOpenCookieValue = sidebarOpenCookie
? sidebarOpenCookie.value === 'false' ? sidebarOpenCookie.value === 'false'
: !config.sidebarCollapsed; : !config.sidebarCollapsed;
const style = layoutStyle.success ? layoutStyle.data : config.style;
return { return {
open: sidebarOpenCookieValue, open: sidebarOpenCookieValue,
style: layoutStyle ?? config.style, style,
}; };
} }

View File

@@ -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) { export async function i18nResolver(language: string, namespace: string) {
const data = await import( const logger = await getLogger();
`../../public/locales/${language}/${namespace}.json`
);
return data as Record<string, string>; try {
const data = await import(
`../../public/locales/${language}/${namespace}.json`
);
return data as Record<string, string>;
} 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 {};
}
} }

View File

@@ -1,7 +1,11 @@
import 'server-only';
import { cache } from 'react'; import { cache } from 'react';
import { cookies, headers } from 'next/headers'; import { cookies, headers } from 'next/headers';
import { z } from 'zod';
import { import {
initializeServerI18n, initializeServerI18n,
parseAcceptLanguageHeader, parseAcceptLanguageHeader,
@@ -32,13 +36,13 @@ const priority = featuresFlagConfig.languagePriority;
*/ */
async function createInstance() { async function createInstance() {
const cookieStore = await cookies(); 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; let selectedLanguage: string | undefined = undefined;
// if the cookie is set, use the language from the cookie // if the cookie is set, use the language from the cookie
if (cookie) { if (langCookieValue) {
selectedLanguage = getLanguageOrFallback(cookie); selectedLanguage = getLanguageOrFallback(langCookieValue);
} }
// if not, check if the language priority is set to user and // if not, check if the language priority is set to user and
@@ -56,10 +60,15 @@ async function createInstance() {
export const createI18nServerInstance = cache(createInstance); export const createI18nServerInstance = cache(createInstance);
/**
* @name getPreferredLanguageFromBrowser
* Get the user's preferred language from the accept-language header.
*/
async function getPreferredLanguageFromBrowser() { async function getPreferredLanguageFromBrowser() {
const headersStore = await headers(); const headersStore = await headers();
const acceptLanguage = headersStore.get('accept-language'); const acceptLanguage = headersStore.get('accept-language');
// no accept-language header, return
if (!acceptLanguage) { if (!acceptLanguage) {
return; return;
} }
@@ -67,16 +76,23 @@ async function getPreferredLanguageFromBrowser() {
return parseAcceptLanguageHeader(acceptLanguage, languages)[0]; 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 ?? '')) { if (language.success) {
console.warn( return language.data;
`Language "${language}" is not supported. Falling back to "${languages[0]}"`,
);
selectedLanguage = languages[0];
} }
return selectedLanguage; console.warn(
`The language passed is invalid. Defaulted back to "${languages[0]}"`,
);
return languages[0];
} }

View File

@@ -1,6 +1,28 @@
import { cookies } from 'next/headers'; 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 * @name getRootTheme
@@ -9,8 +31,19 @@ type Theme = 'light' | 'dark' | 'system';
*/ */
export async function getRootTheme() { export async function getRootTheme() {
const cookiesStore = await cookies(); 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;
} }