# 🔒 Säkerhetshärdningsplan för Recipe-App (Flutter + NestJS + MariaDB) # Nyheter och förbättringar (2026-05-10) - **Admin-inventarie:** Endast admin har tillgång till CRUD, merge, filter, sortering och preview för alla användares inventarieposter. Endpoints och UI är skyddade med @Roles('admin') och testade. - **User-scope och IDOR-skydd:** Inventory och produkter är strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR. - **Säkerhetshärdning:** DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat. - **Optimeringar:** DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad. - **Testtäckning:** Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden. **Reviderad:** 2026-05-07 — baserad på faktisk kodgranskning av repo. **Mål:** Täppa till IDOR, Full Table Dump, och andra kritiska säkerhetshål i **backend (NestJS/Prisma/MariaDB)**, **Flutter-frontend**, och **infrastruktur (Docker/Gitea/Ubuntu)**. **Prioritet:** CRITICAL → HIGH → MEDIUM --- ## ✅ **Redan implementerat — kräver ingen åtgärd** Följande är bekräftat implementerat i koden och behöver inte åtgärdas: | Åtgärd | Var | |---|---| | Helmet med HSTS, X-Frame-Options, CORP, COOP, Referrer-Policy | `backend/src/main.ts` | | CORS begränsat till `ALLOWED_ORIGIN` (env-var) | `backend/src/main.ts` | | Global `JwtAuthGuard` (alla endpoints kräver JWT om inte `@Public()`) | `backend/src/app.module.ts` | | Global `RolesGuard` med `@Roles('admin')` decorator | `backend/src/app.module.ts` | | Global `ThrottlerGuard` (120 anrop/min), 10/min på login/register | `backend/src/app.module.ts`, `auth.controller.ts` | | `ValidationPipe` med `whitelist` + `forbidNonWhitelisted` (förhindrar mass assignment) | `backend/src/main.ts` | | bcrypt salt 12 för lösenordshashning | `backend/src/auth/auth.service.ts` | | IDOR-skydd: `recipes`, `pantry`, `meal-plan`, `receipt-alias` filtrerar på `userId` | Respektive controllers | | Produkter är user-scoped (`ownerId` på `Product`) | `backend/prisma/schema.prisma` | | Hemligheter som env-vars i compose, inga hårdkodade värden | `compose.yml` | | `.env.*` i `.gitignore` | `.gitignore` | | Token-lagringsabstraktion i Flutter (`ITokenStorage`) | `flutter/lib/core/platform/token_storage.dart` | | Premium/admin-guard (`PremiumOrAdminGuard`) | `backend/src/auth/premium-or-admin.guard.ts` | --- --- ## 📌 **0. Förberedelser** ### 0.1. Miljö och verktyg - **Skapa en `security-audit`-gren:** ```bash git checkout -b security-audit-$(date +%Y%m%d) ``` --- --- ## 🚨 **1. IDOR + Full Table Dump i Inventory — CRITICAL** **Problem:** `InventoryItem` saknar `userId`-fält i Prisma-schemat och `inventory.controller.ts` använder inte `@CurrentUser()` alls. Alla autentiserade användare kan läsa, modifiera och radera varandras inventarieposter via `GET/POST/PATCH/DELETE /api/inventory`. **Nuläge i koden:** - `InventoryItem`-modellen i `schema.prisma` saknar `userId`-kolumn - `inventory.controller.ts` har noll anrop till `@CurrentUser()` - `inventory.service.ts` metoder `findAll()`, `findExpiring()`, `update()`, `remove()` tar inte in `userId` **Lösning — 3 steg:** ### Steg 1: Lägg till `userId` i Prisma-schemat ```prisma // backend/prisma/schema.prisma model InventoryItem { id Int @id @default(autoincrement()) userId Int // NY KOLUMN productId Int // ...övriga fält oförändrade... user User @relation(fields: [userId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Cascade) consumptions InventoryConsumption[] @@index([productId]) @@index([userId]) // NY INDEX } // Lägg till i User-modellen: model User { // ... inventoryItems InventoryItem[] // NY RELATION } ``` Skapa migrerings-SQL i `backend/prisma/migrations/{timestamp}_add_userid_to_inventory/migration.sql`: ```sql ALTER TABLE `InventoryItem` ADD COLUMN `userId` INT NOT NULL DEFAULT 1; -- Backfill: sätt userId baserat på produktens ägare UPDATE `InventoryItem` ii JOIN `Product` p ON ii.productId = p.id SET ii.userId = p.ownerId; ALTER TABLE `InventoryItem` ADD CONSTRAINT `fk_inventory_user` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE; CREATE INDEX `InventoryItem_userId_idx` ON `InventoryItem`(`userId`); ``` > **OBS:** Granska backfill-SQL mot din data innan körning — om produkter saknar ägare kan DEFAULT 1 ge fel. ### Steg 2: Uppdatera service-metoderna ```typescript // backend/src/inventory/inventory.service.ts // findAll — lägg till userId-parameter och where-klausul async findAll(userId: number, query?: InventoryQuery) { const where: Prisma.InventoryItemWhereInput = { userId }; // ...resten oförändrat... } // findExpiring — filtrera på userId async findExpiring(userId: number) { return this.prisma.inventoryItem.findMany({ where: { userId, bestBeforeDate: { not: null, gte: new Date() } }, // ... }); } // create — spara userId async create(userId: number, data: CreateInventoryDto) { return this.prisma.inventoryItem.create({ data: { ...data, userId, quantity: new Prisma.Decimal(data.quantity) }, // ... }); } // update — verifiera ägarskap innan uppdatering async update(id: number, userId: number, data: UpdateInventoryDto) { const existing = await this.findInventoryItemByIdOrThrow(id); if (existing.userId !== userId) throw new ForbiddenException('Åtkomst nekad'); // ...resten oförändrat... } // remove — verifiera ägarskap innan borttagning async remove(id: number, userId: number) { const existing = await this.findInventoryItemByIdOrThrow(id); if (existing.userId !== userId) throw new ForbiddenException('Åtkomst nekad'); return this.prisma.inventoryItem.delete({ where: { id } }); } // consume — verifiera ägarskap async consume(id: number, userId: number, body: ConsumeInventoryDto) { const existing = await this.findInventoryItemByIdOrThrow(id); if (existing.userId !== userId) throw new ForbiddenException('Åtkomst nekad'); // ...resten oförändrat... } ``` ### Steg 3: Uppdatera controller ```typescript // backend/src/inventory/inventory.controller.ts import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { ForbiddenException } from '@nestjs/common'; @Controller('inventory') export class InventoryController { @Get() findAll( @CurrentUser() user: { userId: number }, @Query('location') location?: string, @Query('sort') sort?: string, ) { return this.inventoryService.findAll(user.userId, { location, sort }); } @Get('expiring') findExpiring(@CurrentUser() user: { userId: number }) { return this.inventoryService.findExpiring(user.userId); } @Post() create( @CurrentUser() user: { userId: number }, @Body() body: CreateInventoryDto, ) { return this.inventoryService.create(user.userId, body); } @Patch(':id') update( @CurrentUser() user: { userId: number }, @Param('id', ParseIntPipe) id: number, @Body() body: UpdateInventoryDto, ) { return this.inventoryService.update(id, user.userId, body); } @Delete(':id') remove( @CurrentUser() user: { userId: number }, @Param('id', ParseIntPipe) id: number, ) { return this.inventoryService.remove(id, user.userId); } @Post(':id/consume') consume( @CurrentUser() user: { userId: number }, @Param('id', ParseIntPipe) id: number, @Body() body: ConsumeInventoryDto, ) { return this.inventoryService.consume(id, user.userId, body); } @Get(':id/consumption-history') findConsumptionHistory( @CurrentUser() user: { userId: number }, @Param('id', ParseIntPipe) id: number, ) { // Verifiera ägarskap på item innan historik returneras return this.inventoryService.findConsumptionHistory(id, user.userId); } } ``` ### Steg 4: Testa ```typescript // backend/src/inventory/inventory.service.spec.ts describe('IDOR-skydd inventory', () => { it('nekar åtkomst till annan användares post vid update', async () => { prismaMock.inventoryItem.findUnique.mockResolvedValue({ id: 1, userId: 2 } as any); await expect(service.update(1, 1, {})).rejects.toThrow(ForbiddenException); }); it('nekar åtkomst till annan användares post vid remove', async () => { prismaMock.inventoryItem.findUnique.mockResolvedValue({ id: 1, userId: 2 } as any); await expect(service.remove(1, 1)).rejects.toThrow(ForbiddenException); }); }); ``` --- --- ## �️ **2. dist/ saknas i .gitignore — HIGH** **Problem:** `backend/dist/` och `backend/tsconfig.tsbuildinfo` är committade i repot och orsakar merge-konflikter vid deploy (bekräftat 2026-05-07). **Lösning:** Lägg till i `.gitignore` (rotnivå): ```gitignore # Kompilerat backend-output — byggs i Docker, ska ej spåras backend/dist/ backend/tsconfig.tsbuildinfo ``` Rensa sedan från git-historiken (enbart lokal tracking): ```bash git rm -r --cached backend/dist/ backend/tsconfig.tsbuildinfo git commit -m "chore: untrack compiled backend dist files" git push ``` På servern, kör sedan: ```bash git checkout -- backend/dist/ backend/tsconfig.tsbuildinfo 2>/dev/null; git pull ``` --- --- ## 🔑 **3. .gitignore täcker inte bare `.env` — MEDIUM** **Problem:** `.gitignore` har `.env.*` (täcker `.env.local`, `.env.production` etc.) men INTE ett eventuellt `.env` utan suffix. **Lösning:** Lägg till i `.gitignore`: ```gitignore .env .env.* !.env.example ``` **Skapa `.env.example`** (finns ej i repot): ```env DATABASE_URL=mysql://user:password@localhost:3306/recipe_app JWT_SECRET=minst-32-tecken-slumpad-sträng MARIADB_ROOT_PASSWORD= MARIADB_DATABASE=recipe_app MISTRAL_API_KEY= NEXT_PUBLIC_APP_URL=https://recept.example.com ADMIN_NADMIN_PASSWORD= ADMIN_PADMIN_PASSWORD= SEED_USER1_PASSWORD= SEED_USER2_PASSWORD= ALLOWED_ORIGIN=https://recept.example.com RECEIPT_TRACE_DECISIONS=0 ``` --- --- ## 📱 **4. Flutter — tokenlagring på webben — MEDIUM** **Nuläge:** `WebTokenStorage` använder `SharedPreferences` som mappar till `localStorage` i webbläsaren. JWT i localStorage är åtkomlig via JavaScript och kan stjälas vid XSS. **Realistisk åtgärd för en Flutter Web-app:** Det finns inget httpOnly-cookie-alternativ direkt i Flutter Web utan backend-stöd. Om XSS-risken bedöms hög, överväg: 1. Backend sätter token som httpOnly cookie vid login — Flutter Web läser aldrig token direkt 2. Alternativt: sessionStorage (rensas vid stängt fönster) via JS-interop **Nuvarande abstraktionslager är korrekt strukturerat** (`ITokenStorage` → `WebTokenStorage`). Byt ut implementationen vid behov utan att ändra resten av koden. > ℹ️ `flutter_secure_storage` fungerar **inte** på web — det är korrekt att web-adaptern använder SharedPreferences. --- --- ## 🔌 **5. Webhook-säkerhet — LOW (om Gitea-webhooks används)** **Nuläge:** Inga webhook-endpoints hittades i koden. Åtgärd krävs endast om Gitea-webhooks kopplas in. **Om webhooks läggs till** — validera `X-Gitea-Signature` med `crypto.timingSafeEqual`: ```typescript // src/webhooks/gitea.controller.ts import { Controller, Post, Headers, Body, RawBodyRequest, Req, UnauthorizedException } from '@nestjs/common'; import * as crypto from 'crypto'; import { Public } from '../auth/decorators/public.decorator'; @Controller('webhooks/gitea') export class GiteaController { @Public() @Post() async handleWebhook( @Headers('x-gitea-signature-256') signature: string, @Req() req: RawBodyRequest, ) { const secret = process.env.GITEA_WEBHOOK_SECRET; if (!secret) throw new Error('GITEA_WEBHOOK_SECRET saknas'); const hmac = crypto.createHmac('sha256', secret); hmac.update(req.rawBody!); const expected = `sha256=${hmac.digest('hex')}`; if (!crypto.timingSafeEqual(Buffer.from(signature ?? ''), Buffer.from(expected))) { throw new UnauthorizedException('Ogiltig signatur'); } // Hantera event... } } ``` Lägg till `GITEA_WEBHOOK_SECRET` i `.env.example`. --- --- ## 🧪 **6. CI/CD-säkerhetstester — MEDIUM** > ℹ️ Projektet använder **Gitea** (inte GitHub), så GitHub Actions-workflows i originalplanen fungerar inte. Nuvarande CI: tester körs via `npm test` på push (pipeline finns i Gitea). **Utöka befintlig pipeline** med: - `npm audit --audit-level=high` — kontrollera kända CVE:er i beroenden - `npx prisma validate` — verifiera schema-integritet - Lägg till `gitleaks` som pre-commit hook lokalt (körs inte i Docker-build) ```bash # Installera som lokal pre-commit hook npm install -g gitleaks gitleaks protect --staged # kör före varje commit ``` --- --- ## ✅ **7. Avslutande Checklista** | **Åtgärd** | **Status** | **Ansvarsområde** | | -------------------------------- | ---------- | ----------------------- | | IDOR-skydd: recipes, pantry, meal-plan, receipt-alias | ✅ Klart | Backend | | IDOR-skydd + userId-filtrering för **inventory** | ✅ Klart | Backend (NestJS/Prisma) | | `dist/` tillagd i `.gitignore` | ✅ Klart | Git | | `.env` (utan suffix) i `.gitignore` + `.env.example` | ✅ Klart | Git | | Helmet, CORS, ThrottlerGuard, ValidationPipe | ✅ Klart | Backend | | Flutter: token-abstraktion (`ITokenStorage`) | ✅ Klart | Flutter | | Flutter: httpOnly cookie-alternativ (om XSS är reell risk) | ⬜ LOW | Flutter + Backend | | Gitea webhook-signaturvalidering (om webhooks används) | ⬜ LOW | Backend | | `npm audit` i CI-pipeline | ✅ Klart | CI/CD (Gitea) | --- --- ## 🎯 **8. Prioriterad Ordning för Implementering** 1. **Inventory IDOR + userId-fält** (CRITICAL) — ✅ KLART 2026-05-07 2. **dist/ i .gitignore** (HIGH) — ✅ KLART 2026-05-07 3. **bare .env + .env.example** (MEDIUM) — ✅ KLART 2026-05-07 4. **npm audit i CI** (MEDIUM) — ✅ KLART 2026-05-07 5. **Flutter httpOnly cookies** (LOW — kräver arkitekturförändring) 6. **Gitea webhook-validering** (LOW — bara relevant om webhooks används) --- ## 2026-05-07: Sammanfattning av senaste säkerhetsförbättringar - **Inventory är nu user-scopad:** Alla inventory-operationer kräver och filtrerar på userId i backend (schema, migration, service, controller, tester). - **IDOR-skydd för inventory:** Det är nu omöjligt för användare att läsa eller ändra andras inventarieposter. Tester verifierar att åtkomst nekas vid försök till IDOR. - **.gitignore och deploy-hygien:** backend/dist och backend/tsconfig.tsbuildinfo ignoreras och är ej längre spårade i git. .env och .env.* ignoreras, men .env.example finns och är uppdaterad. - **CI/CD-härdning:** npm audit och prisma validate körs i pipeline. Alla tester och byggen måste passera. ## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.