diff --git a/.env.example b/.env.example
index 2f8c5670..d2bbd91f 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md
index 4397d68b..98f3cef4 100644
--- a/NEXT_STEPS.md
+++ b/NEXT_STEPS.md
@@ -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 |
diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md
index 72c8780a..e89f995f 100644
--- a/TEKNISK_BESKRIVNING.md
+++ b/TEKNISK_BESKRIVNING.md
@@ -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.**
diff --git a/backend/package.json b/backend/package.json
index 41841e45..e478ebb4 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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",
diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts
index 6ced92a8..dde5409d 100644
--- a/backend/src/app.module.ts
+++ b/backend/src/app.module.ts
@@ -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,
diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts
index 9afeb65b..51e7313b 100644
--- a/backend/src/auth/auth.controller.ts
+++ b/backend/src/auth/auth.controller.ts
@@ -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) {
diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts
index 2a24e820..108f31d4 100644
--- a/backend/src/auth/auth.module.ts
+++ b/backend/src/auth/auth.module.ts
@@ -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' },
}),
],
diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts
index bb48e096..eb9d4d4e 100644
--- a/backend/src/auth/jwt.strategy.ts
+++ b/backend/src/auth/jwt.strategy.ts
@@ -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,
});
}
diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts
index a3a97570..c54777b2 100644
--- a/backend/src/products/products.controller.ts
+++ b/backend/src/products/products.controller.ts
@@ -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 } },
diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts
index 3a7b1eb0..ff7d9cb9 100644
--- a/backend/src/receipt-import/receipt-import.controller.ts
+++ b/backend/src/receipt-import/receipt-import.controller.ts
@@ -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', {
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 36a310bd..b46abc5b 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -10,9 +10,6 @@ export default function HomePage() {
Gå till varor som finns hemma
-
- Gå till produktadmin
-
Gå till recept
diff --git a/frontend/app/recipes/[id]/RecipeDetailClient.tsx b/frontend/app/recipes/[id]/RecipeDetailClient.tsx
index 51bafb21..f17a4c78 100644
--- a/frontend/app/recipes/[id]/RecipeDetailClient.tsx
+++ b/frontend/app/recipes/[id]/RecipeDetailClient.tsx
@@ -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(null);
const [previewError, setPreviewError] = useState(null);
- const [isPreviewing, startPreviewTransition] = useTransition();
+ const [isPreviewing, setIsPreviewing] = useState(false);
+ const previewSectionRef = useRef(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) && (
-
+
🛒 Vad behöver jag köpa?
{previewError &&
{previewError}
}
{preview && (
diff --git a/produktlansering.md b/produktlansering.md
index 49d8b6ca..d44bc195 100644
--- a/produktlansering.md
+++ b/produktlansering.md
@@ -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