feat: implement security headers and rate limiting; update environment variables and documentation
This commit is contained in:
+14
-1
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' },
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -10,9 +10,6 @@ export default function HomePage() {
|
||||
<Link href="/inventory" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
|
||||
Gå till varor som finns hemma
|
||||
</Link>
|
||||
<Link href="/admin/products" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
|
||||
Gå till produktadmin
|
||||
</Link>
|
||||
<Link href="/recipes" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
|
||||
Gå 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 () => {
|
||||
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());
|
||||
} catch (err) {
|
||||
setPreviewError(err instanceof Error ? err.message : 'Fel vid hämtning av inventariedata');
|
||||
}
|
||||
});
|
||||
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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user