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:
committed by
GitHub
parent
1327a8efb7
commit
e193c94f06
59
.cursor/rules/otp.mdc
Normal file
59
.cursor/rules/otp.mdc
Normal 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
152
.cursor/rules/security.mdc
Normal 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
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 logger = await getLogger();
|
||||
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user