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_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)
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)
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 |
|---|---|
@@ -47,6 +47,11 @@
| Produktstatus (pending / active / rejected) | ✅ Klart |
| Admin: Väntande produktförslag (pending-sida) | ✅ 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 |
| 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
> **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",
"tesseract.js": "^6.0.1",
"uuid": "^11.1.0",
"helmet": "^8.0.0"
"helmet": "^8.0.0",
"@nestjs/throttler": "^6.4.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
+12
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { ProductsModule } from './products/products.module';
@@ -21,6 +22,13 @@ import { RolesGuard } from './auth/roles.guard';
@Module({
imports: [
ThrottlerModule.forRoot([
{
name: 'default',
ttl: 60_000, // 1 minut
limit: 120, // 120 anrop per minut (generellt)
},
]),
HealthModule,
PrismaModule,
ProductsModule,
@@ -38,6 +46,10 @@ import { RolesGuard } from './auth/roles.guard';
AiModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
+3
View File
@@ -1,4 +1,5 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@@ -9,12 +10,14 @@ export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Public()
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@HttpCode(HttpStatus.OK)
@Post('login')
login(@Body() dto: LoginDto) {
+5 -1
View File
@@ -11,7 +11,11 @@ import { UsersModule } from '../users/users.module';
UsersModule,
PassportModule,
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' },
}),
],
+3 -1
View File
@@ -5,10 +5,12 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET saknas i miljövariabler');
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET ?? 'changeme',
secretOrKey: secret,
});
}
@@ -13,6 +13,7 @@ import {
Query,
Request,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { Public } from '../auth/decorators/public.decorator';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
@@ -89,6 +90,7 @@ export class ProductsController {
@Roles('admin')
@Post('ai-categorize-bulk')
@Throttle({ default: { ttl: 60_000, limit: 5 } })
@HttpCode(200)
async aiCategorizeBulk(@Body() body: AiCategorizeBulkDto) {
const categories = await this.categoriesService.findFlattened();
@@ -113,6 +115,7 @@ export class ProductsController {
}
@Get(':id/suggest-category')
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async suggestCategory(
@Param('id', ParseIntPipe) id: number,
@Request() req: { user: { role: string; isPremium: boolean } },
@@ -7,6 +7,7 @@ import {
UseInterceptors,
BadRequestException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { ReceiptImportService } from './receipt-import.service';
@@ -27,6 +28,7 @@ export class ReceiptImportController {
constructor(private readonly receiptImportService: ReceiptImportService) {}
@Post()
@Throttle({ default: { ttl: 60_000, limit: 20 } })
@UseGuards(JwtAuthGuard)
@UseInterceptors(
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' }}>
till varor som finns hemma
</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' }}>
till recept
</Link>
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useTransition } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthFetch } from '../../../lib/use-auth-fetch';
import type {
@@ -96,7 +96,8 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
// Inventarieförhandsgranskning
const [preview, setPreview] = useState<RecipeInventoryPreview | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isPreviewing, startPreviewTransition] = useTransition();
const [isPreviewing, setIsPreviewing] = useState(false);
const previewSectionRef = useRef<HTMLElement>(null);
// Bilduppdatering
const [imageUrlInput, setImageUrlInput] = useState('');
@@ -124,17 +125,23 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
};
// ── Inventarieförhandsgranskning ──
const loadPreview = () => {
const loadPreview = async () => {
if (preview) {
previewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
setPreviewError(null);
startPreviewTransition(async () => {
setIsPreviewing(true);
try {
const res = await fetch(`/api/recipe-preview-proxy?id=${recipe.id}`, { cache: 'no-store' });
if (!res.ok) throw new Error(await parseErrorResponse(res));
setPreview(await res.json());
setTimeout(() => previewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
} catch (err) {
setPreviewError(err instanceof Error ? err.message : 'Fel vid hämtning av inventariedata');
} finally {
setIsPreviewing(false);
}
});
};
// ── Ta bort recept ──
@@ -313,7 +320,7 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
{/* Lagergranskning */}
{(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>
{previewError && <p style={errorStyle}>{previewError}</p>}
{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
- 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)
- Kontrollera och dokumentera rollhantering och accesskontroller
### Steg-för-steg:
1. Identifiera alla fält som ska krypteras och implementera kryptering/dekryptering i backend
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
5. Dokumentera och testa roll/accesskontroller