Initial commit
This commit is contained in:
424
skills/security-practices/SKILL.md
Normal file
424
skills/security-practices/SKILL.md
Normal file
@@ -0,0 +1,424 @@
|
||||
---
|
||||
name: grey-haven-security-practices
|
||||
description: Grey Haven's security best practices - input validation, output sanitization, multi-tenant RLS, secret management with Doppler, rate limiting, OWASP Top 10 for TanStack/FastAPI stack. Use when implementing security-critical features.
|
||||
---
|
||||
|
||||
# Grey Haven Security Practices
|
||||
|
||||
Follow Grey Haven Studio's security best practices for TanStack Start and FastAPI applications.
|
||||
|
||||
## Secret Management with Doppler
|
||||
|
||||
**CRITICAL**: NEVER commit secrets to git. Always use Doppler.
|
||||
|
||||
### Doppler Setup
|
||||
|
||||
```bash
|
||||
# Install Doppler CLI
|
||||
brew install dopplerhq/cli/doppler
|
||||
|
||||
# Authenticate
|
||||
doppler login
|
||||
|
||||
# Setup project
|
||||
cd /path/to/project
|
||||
doppler setup
|
||||
|
||||
# Access secrets
|
||||
doppler run -- npm run dev # TypeScript
|
||||
doppler run -- python app/main.py # Python
|
||||
```
|
||||
|
||||
### Required Secrets (Doppler)
|
||||
|
||||
```bash
|
||||
# Auth
|
||||
BETTER_AUTH_SECRET=<random-32-bytes>
|
||||
JWT_SECRET_KEY=<random-32-bytes>
|
||||
|
||||
# Database
|
||||
DATABASE_URL_ADMIN=postgresql://...
|
||||
DATABASE_URL_AUTHENTICATED=postgresql://...
|
||||
|
||||
# APIs
|
||||
RESEND_API_KEY=re_...
|
||||
STRIPE_SECRET_KEY=sk_...
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# OAuth
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-...
|
||||
GITHUB_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
### Accessing Secrets in Code
|
||||
|
||||
```typescript
|
||||
// [OK] Correct - Use process.env (Doppler provides at runtime)
|
||||
const apiKey = process.env.OPENAI_API_KEY!;
|
||||
|
||||
// [X] Wrong - Hardcoded secrets
|
||||
const apiKey = "sk-..."; // NEVER DO THIS!
|
||||
```
|
||||
|
||||
```python
|
||||
# [OK] Correct - Use os.getenv (Doppler provides at runtime)
|
||||
import os
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
# [X] Wrong - Hardcoded secrets
|
||||
api_key = "sk-..." # NEVER DO THIS!
|
||||
```
|
||||
|
||||
## Input Validation
|
||||
|
||||
### TypeScript (Zod Validation)
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
// [OK] Validate all user input
|
||||
const UserCreateSchema = z.object({
|
||||
email_address: z.string().email().max(255),
|
||||
name: z.string().min(1).max(100),
|
||||
age: z.number().int().min(0).max(150),
|
||||
});
|
||||
|
||||
export const createUser = createServerFn("POST", async (data: unknown) => {
|
||||
// Validate input
|
||||
const validated = UserCreateSchema.parse(data);
|
||||
|
||||
// Now safe to use
|
||||
await db.insert(users).values(validated);
|
||||
});
|
||||
```
|
||||
|
||||
### Python (Pydantic Validation)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""User creation schema with validation."""
|
||||
email_address: EmailStr
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
age: int = Field(ge=0, le=150)
|
||||
|
||||
@validator("name")
|
||||
def name_must_not_contain_special_chars(cls, v):
|
||||
if not v.replace(" ", "").isalnum():
|
||||
raise ValueError("Name must be alphanumeric")
|
||||
return v
|
||||
|
||||
@router.post("/users", response_model=UserResponse)
|
||||
async def create_user(data: UserCreate):
|
||||
# Pydantic validates automatically
|
||||
# data is now safe to use
|
||||
pass
|
||||
```
|
||||
|
||||
## Output Sanitization
|
||||
|
||||
### HTML Escaping (XSS Prevention)
|
||||
|
||||
```typescript
|
||||
// [OK] React automatically escapes in JSX
|
||||
function UserProfile({ user }: { user: User }) {
|
||||
return <div>{user.name}</div>; // Safe, auto-escaped
|
||||
}
|
||||
|
||||
// [X] Dangerous - Raw HTML
|
||||
function UserProfile({ user }: { user: User }) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: user.bio }} />; // UNSAFE!
|
||||
}
|
||||
|
||||
// [OK] If HTML needed, sanitize first
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
function UserProfile({ user }: { user: User }) {
|
||||
const sanitized = DOMPurify.sanitize(user.bio);
|
||||
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
|
||||
}
|
||||
```
|
||||
|
||||
### SQL Injection Prevention
|
||||
|
||||
```typescript
|
||||
// [OK] Use parameterized queries (Drizzle)
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.email_address, email), // Safe, parameterized
|
||||
});
|
||||
|
||||
// [X] Never concatenate SQL
|
||||
const user = await db.execute(
|
||||
`SELECT * FROM users WHERE email = '${email}'` // SQL INJECTION!
|
||||
);
|
||||
```
|
||||
|
||||
```python
|
||||
# [OK] Use ORM (SQLModel/SQLAlchemy)
|
||||
user = await session.execute(
|
||||
select(User).where(User.email_address == email) # Safe, parameterized
|
||||
)
|
||||
|
||||
# [X] Never concatenate SQL
|
||||
user = await session.execute(
|
||||
f"SELECT * FROM users WHERE email = '{email}'" # SQL INJECTION!
|
||||
)
|
||||
```
|
||||
|
||||
## Multi-Tenant Security (RLS)
|
||||
|
||||
### Enable RLS on All Tables
|
||||
|
||||
```sql
|
||||
-- ALWAYS enable RLS for multi-tenant tables
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
### Tenant Isolation Policies
|
||||
|
||||
```sql
|
||||
-- Authenticated users see only their tenant's data
|
||||
CREATE POLICY "Tenant isolation for users"
|
||||
ON users FOR ALL TO authenticated
|
||||
USING (tenant_id = (current_setting('request.jwt.claims')::json->>'tenant_id')::uuid);
|
||||
```
|
||||
|
||||
### Always Include tenant_id in Queries
|
||||
|
||||
```typescript
|
||||
// [OK] Correct - Tenant isolation enforced
|
||||
export const getUser = createServerFn("GET", async (userId: string) => {
|
||||
const session = await getSession();
|
||||
const tenantId = session.user.tenant_id;
|
||||
|
||||
return await db.query.users.findFirst({
|
||||
where: and(
|
||||
eq(users.id, userId),
|
||||
eq(users.tenant_id, tenantId) // REQUIRED!
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
// [X] Wrong - Missing tenant check (security vulnerability!)
|
||||
export const getUser = createServerFn("GET", async (userId: string) => {
|
||||
return await db.query.users.findFirst({
|
||||
where: eq(users.id, userId), // Can access ANY tenant's users!
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Redis-Based Rate Limiting
|
||||
|
||||
```typescript
|
||||
import { Redis } from "@upstash/redis";
|
||||
|
||||
// Doppler provides REDIS_URL
|
||||
const redis = new Redis({ url: process.env.REDIS_URL! });
|
||||
|
||||
async function rateLimit(identifier: string, limit: number, window: number) {
|
||||
const key = `rate-limit:${identifier}`;
|
||||
const count = await redis.incr(key);
|
||||
|
||||
if (count === 1) {
|
||||
await redis.expire(key, window);
|
||||
}
|
||||
|
||||
if (count > limit) {
|
||||
throw new Error("Rate limit exceeded");
|
||||
}
|
||||
|
||||
return { success: true, remaining: limit - count };
|
||||
}
|
||||
|
||||
export const sendEmail = createServerFn("POST", async (data) => {
|
||||
const session = await getSession();
|
||||
|
||||
// Rate limit: 10 emails per hour per user
|
||||
await rateLimit(`email:${session.user.id}`, 10, 3600);
|
||||
|
||||
// Send email...
|
||||
});
|
||||
```
|
||||
|
||||
## Authentication Security
|
||||
|
||||
### Password Requirements
|
||||
|
||||
```typescript
|
||||
const PasswordSchema = z.string()
|
||||
.min(12, "Password must be at least 12 characters")
|
||||
.regex(/[A-Z]/, "Must contain uppercase letter")
|
||||
.regex(/[a-z]/, "Must contain lowercase letter")
|
||||
.regex(/[0-9]/, "Must contain number")
|
||||
.regex(/[^A-Za-z0-9]/, "Must contain special character");
|
||||
```
|
||||
|
||||
### Session Security
|
||||
|
||||
```typescript
|
||||
// lib/server/auth.ts
|
||||
export const auth = betterAuth({
|
||||
session: {
|
||||
expiresIn: 7 * 24 * 60 * 60, // 7 days
|
||||
updateAge: 24 * 60 * 60, // Refresh daily
|
||||
cookieOptions: {
|
||||
httpOnly: true, // Prevent XSS
|
||||
secure: true, // HTTPS only
|
||||
sameSite: "lax", // CSRF protection
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
```typescript
|
||||
// TanStack Start
|
||||
import { cors } from "@elysiajs/cors";
|
||||
|
||||
app.use(cors({
|
||||
origin: [
|
||||
"https://app.greyhaven.studio",
|
||||
"https://admin.greyhaven.studio",
|
||||
],
|
||||
credentials: true,
|
||||
maxAge: 86400,
|
||||
}));
|
||||
```
|
||||
|
||||
```python
|
||||
# FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://app.greyhaven.studio",
|
||||
"https://admin.greyhaven.studio",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
allow_headers=["*"],
|
||||
max_age=86400,
|
||||
)
|
||||
```
|
||||
|
||||
## File Upload Security
|
||||
|
||||
```typescript
|
||||
// Validate file type and size
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const uploadFile = createServerFn("POST", async (file: File) => {
|
||||
// Validate size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error("File too large");
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
throw new Error("Invalid file type");
|
||||
}
|
||||
|
||||
// Validate content (check file header, not just extension)
|
||||
const buffer = await file.arrayBuffer();
|
||||
const header = new Uint8Array(buffer.slice(0, 4));
|
||||
|
||||
// Check for valid image headers
|
||||
// JPEG: FF D8 FF
|
||||
// PNG: 89 50 4E 47
|
||||
// etc.
|
||||
|
||||
// Generate safe filename (prevent path traversal)
|
||||
const ext = file.name.split(".").pop();
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
|
||||
// Upload to secure storage...
|
||||
});
|
||||
```
|
||||
|
||||
## Environment-Specific Security
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Doppler: dev config
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
DEBUG=true
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Doppler: production config
|
||||
BETTER_AUTH_URL=https://app.greyhaven.studio
|
||||
CORS_ORIGINS=https://app.greyhaven.studio
|
||||
DEBUG=false
|
||||
FORCE_HTTPS=true
|
||||
```
|
||||
|
||||
## Testing Security
|
||||
|
||||
```typescript
|
||||
// tests/integration/security.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("Security", () => {
|
||||
it("prevents tenant data leakage", async () => {
|
||||
// Create user in tenant A
|
||||
const userA = await createUser({ email: "a@example.com", tenantId: "A" });
|
||||
|
||||
// Try to access as tenant B user
|
||||
const sessionB = await loginAs({ tenantId: "B" });
|
||||
const result = await getUserById(userA.id, sessionB);
|
||||
|
||||
// Should return null or 404, not tenant A's user
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("enforces rate limiting", async () => {
|
||||
// Make 11 requests (limit is 10)
|
||||
for (let i = 0; i < 11; i++) {
|
||||
if (i < 10) {
|
||||
await sendEmail({ to: "test@example.com" });
|
||||
} else {
|
||||
await expect(
|
||||
sendEmail({ to: "test@example.com" })
|
||||
).rejects.toThrow("Rate limit exceeded");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## When to Apply This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Handling user input
|
||||
- Implementing authentication
|
||||
- Working with sensitive data
|
||||
- Configuring API endpoints
|
||||
- Writing database queries
|
||||
- Implementing file uploads
|
||||
- Setting up CORS
|
||||
- Managing secrets with Doppler
|
||||
|
||||
## Critical Reminders
|
||||
|
||||
1. **Doppler**: ALWAYS use for secrets (never commit to git)
|
||||
2. **Input validation**: Validate ALL user input (Zod/Pydantic)
|
||||
3. **RLS**: Enable on all multi-tenant tables
|
||||
4. **tenant_id**: ALWAYS filter by tenant_id
|
||||
5. **Rate limiting**: Implement on expensive operations
|
||||
6. **HTTPS only**: Force HTTPS in production
|
||||
7. **SQL injection**: Use ORM, never concatenate SQL
|
||||
8. **XSS**: React auto-escapes, sanitize dangerouslySetInnerHTML
|
||||
9. **CORS**: Whitelist specific origins
|
||||
10. **Sessions**: httpOnly, secure, sameSite cookies
|
||||
Reference in New Issue
Block a user