feat: implement security headers and rate limiting; update environment variables and documentation

This commit is contained in:
Nils-Johan Gynther
2026-04-21 08:06:21 +02:00
parent c1d51c771e
commit 7748ad311f
13 changed files with 133 additions and 23 deletions
+14 -1
View File
@@ -7,9 +7,22 @@ MARIADB_DATABASE=recipe_app
MARIADB_USER=recipe_user MARIADB_USER=recipe_user
MARIADB_PASSWORD=byt-ut-mig MARIADB_PASSWORD=byt-ut-mig
# Auth.js / NextAuth
# Generera med: openssl rand -base64 32
AUTH_SECRET=byt-ut-mig
# JWT (NestJS backend)
# Generera med: openssl rand -base64 32
# OBS: Appen vägrar starta om detta saknas.
JWT_SECRET=byt-ut-mig
# Mistral AI
# Hämtas från: https://console.mistral.ai/
MISTRAL_API_KEY=
# Publik URL (används av frontend) # Publik URL (används av frontend)
NEXT_PUBLIC_APP_URL=https://recept.gynther.se NEXT_PUBLIC_APP_URL=https://recept.gynther.se
NEXT_PUBLIC_API_URL=https://api.recept.gynther.se NEXT_PUBLIC_API_URL=https://recept.gynther.se
# Bootstrap-användare (skapas/uppdateras vid appstart) # Bootstrap-användare (skapas/uppdateras vid appstart)
ADMIN_NADMIN_PASSWORD=byt-ut-mig ADMIN_NADMIN_PASSWORD=byt-ut-mig
+6 -1
View File
@@ -6,7 +6,7 @@
--- ---
## Status — senast genomgånget: 2026-04-19 ## Status — senast genomgånget: 2026-04-21
| Funktion | Status | | Funktion | Status |
|---|---| |---|---|
@@ -47,6 +47,11 @@
| Produktstatus (pending / active / rejected) | ✅ Klart | | Produktstatus (pending / active / rejected) | ✅ Klart |
| Admin: Väntande produktförslag (pending-sida) | ✅ Klart | | Admin: Väntande produktförslag (pending-sida) | ✅ Klart |
| Kvittoimport — AI-kategorisuggestion för premium-användare | ✅ Klart | | Kvittoimport — AI-kategorisuggestion för premium-användare | ✅ Klart |
| Säkerhetshuvuden — Caddy (globala headers) | ✅ Klart |
| Säkerhetshuvuden — Next.js CSP | ✅ Klart |
| Säkerhetshuvuden — NestJS Helmet (backup) | ✅ Klart (aktiveras vid nästa rebuild) |
| Buggfix: "Vad behöver jag köpa" — flimmer och scroll | ✅ Klart |
| Startsida — produktadmin-länk borttagen | ✅ Klart |
| Avancerad AI-integration (veckoplanering, receptförslag) | ❌ Planerad | | Avancerad AI-integration (veckoplanering, receptförslag) | ❌ Planerad |
| EAN-skanning via Open Food Facts API | ❌ Planerad | | EAN-skanning via Open Food Facts API | ❌ Planerad |
+57
View File
@@ -214,6 +214,63 @@ recept.gynther.se {
--- ---
## Säkerhetshuvuden
Säkerhetshuvuden är implementerade i tre lager för djupförsvar:
### Lager 1: Caddy (globalt för alla tjänster)
Sätts i `(common)`-blocket och gäller alla domäner som importerar det.
| Header | Värde | Syfte |
|--------|-------|-------|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` | Tvingar HTTPS, skyddar mot protocol downgrade |
| `X-Content-Type-Options` | `nosniff` | Förhindrar MIME-sniffing |
| `X-Frame-Options` | `DENY` | Skyddar mot clickjacking |
| `X-XSS-Protection` | `1; mode=block` | Legacy XSS-skydd (äldre webbläsare) |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Begränsar referrer-läckage |
| `Permissions-Policy` | `geolocation=(), microphone=(), camera=(), payment=()` | Inaktiverar känsliga webbläsar-API:er |
| `Cross-Origin-Opener-Policy` | `same-origin` | Isolerar browsing context |
| `Cross-Origin-Resource-Policy` | `same-origin` | Förhindrar cross-origin läsning av resurser |
| `Cross-Origin-Embedder-Policy` | `require-corp` | Kräver explicit cross-origin permission |
### Lager 2: NestJS Helmet (backup)
Helmet konfigurerat i `backend/src/main.ts` som säkerhetsbackup ifall Caddy kringgås eller misslyckas. CSP är inaktiverat i Helmet (`contentSecurityPolicy: false`) eftersom det hanteras av Next.js.
```
Aktiveras vid: docker compose up --build backend
```
### Lager 3: Next.js Content Security Policy
CSP sätts i `frontend/next.config.js` via `headers()`-funktionen och gäller alla routes (`/:path*`).
| Direktiv | Tillåtna källor | Motivering |
|----------|----------------|------------|
| `default-src` | `'self'` | Restriktiv default |
| `script-src` | `'self' 'unsafe-eval' 'unsafe-inline'` | Krävs av Next.js runtime |
| `style-src` | `'self' 'unsafe-inline' fonts.googleapis.com` | Inline-stilar + Google Fonts |
| `img-src` | `'self' data: https:` | Tillåter externa bilder via HTTPS |
| `font-src` | `'self' fonts.gstatic.com` | Google Fonts-filer |
| `connect-src` | `'self' api.mistral.ai` | API-anrop inkl. Mistral AI |
| `frame-src` | `'none'` | Inga inbäddade frames tillåtna |
| `object-src` | `'none'` | Inga plugins (Flash, etc.) |
| `base-uri` | `'self'` | Skyddar mot base-tag-injektion |
| `form-action` | `'self'` | Formulär får bara posta till samma origin |
> **Notering:** `'unsafe-eval'` och `'unsafe-inline'` i `script-src` är nödvändiga för Next.js 16 med App Router. Undvik att ta bort dessa utan noggrann testning.
### Felsökning av CSP-brott
Om en funktion slutar fungera efter CSP-aktivering:
1. Öppna webbläsarens devtools → Console för att se CSP-felmeddelanden
2. Kontrollera vilken domän/resurs som blockeras
3. Lägg till domänen i rätt direktiv i `frontend/next.config.js`
4. Vanliga undantag: WebSockets kräver `wss:` i `connect-src`, Service Workers kräver `worker-src 'self'`
---
## Arkitekturprincip: API routes framför Server Actions ## Arkitekturprincip: API routes framför Server Actions
> **Regel: Använd Next.js API routes (`/app/api/...`) för all mutation från klientkomponenter. Använd INTE Server Actions för detta.** > **Regel: Använd Next.js API routes (`/app/api/...`) för all mutation från klientkomponenter. Använd INTE Server Actions för detta.**
+2 -1
View File
@@ -31,7 +31,8 @@
"sharp": "^0.33.5", "sharp": "^0.33.5",
"tesseract.js": "^6.0.1", "tesseract.js": "^6.0.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"helmet": "^8.0.0" "helmet": "^8.0.0",
"@nestjs/throttler": "^6.4.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.3.0", "@nestjs/cli": "^10.3.0",
+12
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { ProductsModule } from './products/products.module'; import { ProductsModule } from './products/products.module';
@@ -21,6 +22,13 @@ import { RolesGuard } from './auth/roles.guard';
@Module({ @Module({
imports: [ imports: [
ThrottlerModule.forRoot([
{
name: 'default',
ttl: 60_000, // 1 minut
limit: 120, // 120 anrop per minut (generellt)
},
]),
HealthModule, HealthModule,
PrismaModule, PrismaModule,
ProductsModule, ProductsModule,
@@ -38,6 +46,10 @@ import { RolesGuard } from './auth/roles.guard';
AiModule, AiModule,
], ],
providers: [ providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: JwtAuthGuard, useClass: JwtAuthGuard,
+3
View File
@@ -1,4 +1,5 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
@@ -9,12 +10,14 @@ export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Public() @Public()
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@Post('register') @Post('register')
register(@Body() dto: RegisterDto) { register(@Body() dto: RegisterDto) {
return this.authService.register(dto); return this.authService.register(dto);
} }
@Public() @Public()
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('login') @Post('login')
login(@Body() dto: LoginDto) { login(@Body() dto: LoginDto) {
+5 -1
View File
@@ -11,7 +11,11 @@ import { UsersModule } from '../users/users.module';
UsersModule, UsersModule,
PassportModule, PassportModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET ?? 'changeme', secret: (() => {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET saknas i miljövariabler');
return secret;
})(),
signOptions: { expiresIn: '7d' }, signOptions: { expiresIn: '7d' },
}), }),
], ],
+3 -1
View File
@@ -5,10 +5,12 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() { constructor() {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET saknas i miljövariabler');
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET ?? 'changeme', secretOrKey: secret,
}); });
} }
@@ -13,6 +13,7 @@ import {
Query, Query,
Request, Request,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { CreateProductDto } from './dto/create-product.dto'; import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto'; import { UpdateProductDto } from './dto/update-product.dto';
@@ -89,6 +90,7 @@ export class ProductsController {
@Roles('admin') @Roles('admin')
@Post('ai-categorize-bulk') @Post('ai-categorize-bulk')
@Throttle({ default: { ttl: 60_000, limit: 5 } })
@HttpCode(200) @HttpCode(200)
async aiCategorizeBulk(@Body() body: AiCategorizeBulkDto) { async aiCategorizeBulk(@Body() body: AiCategorizeBulkDto) {
const categories = await this.categoriesService.findFlattened(); const categories = await this.categoriesService.findFlattened();
@@ -113,6 +115,7 @@ export class ProductsController {
} }
@Get(':id/suggest-category') @Get(':id/suggest-category')
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async suggestCategory( async suggestCategory(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Request() req: { user: { role: string; isPremium: boolean } }, @Request() req: { user: { role: string; isPremium: boolean } },
@@ -7,6 +7,7 @@ import {
UseInterceptors, UseInterceptors,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import { ReceiptImportService } from './receipt-import.service'; import { ReceiptImportService } from './receipt-import.service';
@@ -27,6 +28,7 @@ export class ReceiptImportController {
constructor(private readonly receiptImportService: ReceiptImportService) {} constructor(private readonly receiptImportService: ReceiptImportService) {}
@Post() @Post()
@Throttle({ default: { ttl: 60_000, limit: 20 } })
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@UseInterceptors( @UseInterceptors(
FileInterceptor('file', { FileInterceptor('file', {
-3
View File
@@ -10,9 +10,6 @@ export default function HomePage() {
<Link href="/inventory" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}> <Link href="/inventory" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
till varor som finns hemma till varor som finns hemma
</Link> </Link>
<Link href="/admin/products" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
till produktadmin
</Link>
<Link href="/recipes" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}> <Link href="/recipes" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
till recept till recept
</Link> </Link>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useTransition } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthFetch } from '../../../lib/use-auth-fetch'; import { useAuthFetch } from '../../../lib/use-auth-fetch';
import type { import type {
@@ -96,7 +96,8 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
// Inventarieförhandsgranskning // Inventarieförhandsgranskning
const [preview, setPreview] = useState<RecipeInventoryPreview | null>(null); const [preview, setPreview] = useState<RecipeInventoryPreview | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null); const [previewError, setPreviewError] = useState<string | null>(null);
const [isPreviewing, startPreviewTransition] = useTransition(); const [isPreviewing, setIsPreviewing] = useState(false);
const previewSectionRef = useRef<HTMLElement>(null);
// Bilduppdatering // Bilduppdatering
const [imageUrlInput, setImageUrlInput] = useState(''); const [imageUrlInput, setImageUrlInput] = useState('');
@@ -124,17 +125,23 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
}; };
// ── Inventarieförhandsgranskning ── // ── Inventarieförhandsgranskning ──
const loadPreview = () => { const loadPreview = async () => {
if (preview) {
previewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
setPreviewError(null); setPreviewError(null);
startPreviewTransition(async () => { setIsPreviewing(true);
try { try {
const res = await fetch(`/api/recipe-preview-proxy?id=${recipe.id}`, { cache: 'no-store' }); const res = await fetch(`/api/recipe-preview-proxy?id=${recipe.id}`, { cache: 'no-store' });
if (!res.ok) throw new Error(await parseErrorResponse(res)); if (!res.ok) throw new Error(await parseErrorResponse(res));
setPreview(await res.json()); setPreview(await res.json());
setTimeout(() => previewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
} catch (err) { } catch (err) {
setPreviewError(err instanceof Error ? err.message : 'Fel vid hämtning av inventariedata'); setPreviewError(err instanceof Error ? err.message : 'Fel vid hämtning av inventariedata');
} finally {
setIsPreviewing(false);
} }
});
}; };
// ── Ta bort recept ── // ── Ta bort recept ──
@@ -313,7 +320,7 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
{/* Lagergranskning */} {/* Lagergranskning */}
{(preview || previewError) && ( {(preview || previewError) && (
<section style={{ ...sectionStyle, marginTop: '1.5rem' }}> <section ref={previewSectionRef} style={{ ...sectionStyle, marginTop: '1.5rem' }}>
<h2 style={sectionTitle}>🛒 Vad behöver jag köpa?</h2> <h2 style={sectionTitle}>🛒 Vad behöver jag köpa?</h2>
{previewError && <p style={errorStyle}>{previewError}</p>} {previewError && <p style={errorStyle}>{previewError}</p>}
{preview && ( {preview && (
+6 -2
View File
@@ -6,14 +6,18 @@ Denna plan bryter ner de viktigaste områdena som behöver åtgärdas för att t
- Kryptera känsliga användaruppgifter i databasen (t.ex. e-post, namn) med AES-256-GCM - Kryptera känsliga användaruppgifter i databasen (t.ex. e-post, namn) med AES-256-GCM
- Inför rate limiting på API och AI-endpoints (t.ex. @nestjs/throttler) - Inför rate limiting på API och AI-endpoints (t.ex. @nestjs/throttler)
- Lägg till säkerhetshuvuden (Helmet i backend, CSP i frontend) - Lägg till säkerhetshuvuden (Helmet i backend, CSP i frontend)**Klart 2026-04-21**
- Caddy: globala headers (HSTS, X-Frame-Options, CORP, COOP, COEP, Referrer-Policy, Permissions-Policy)
- Next.js: CSP via `next.config.js` med `headers()`-funktionen
- NestJS: Helmet konfigurerat i `main.ts` som backup (aktiveras vid nästa rebuild)
- Dokumenterat i TEKNISK_BESKRIVNING.md under "Säkerhetshuvuden"
- Se över hantering av miljövariabler och secrets (ingen hårdkodning) - Se över hantering av miljövariabler och secrets (ingen hårdkodning)
- Kontrollera och dokumentera rollhantering och accesskontroller - Kontrollera och dokumentera rollhantering och accesskontroller
### Steg-för-steg: ### Steg-för-steg:
1. Identifiera alla fält som ska krypteras och implementera kryptering/dekryptering i backend 1. Identifiera alla fält som ska krypteras och implementera kryptering/dekryptering i backend
2. Lägg till och konfigurera rate limiting i NestJS 2. Lägg till och konfigurera rate limiting i NestJS
3. Lägg till Helmet och CSP-konfiguration 3. Lägg till Helmet och CSP-konfiguration**Klart**
4. Gå igenom compose.yml och kodbas för att säkerställa att alla hemligheter ligger i .env 4. Gå igenom compose.yml och kodbas för att säkerställa att alla hemligheter ligger i .env
5. Dokumentera och testa roll/accesskontroller 5. Dokumentera och testa roll/accesskontroller