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