Files
recipe-app/Säkerhetshärdningsplan för Recipe-app.md
T
Nils-Johan Gynther f7446cc2df
Test Suite / test (24.15.0) (push) Has been cancelled
feat: enhance security with user-scoped inventory and IDOR protection
2026-05-07 12:00:57 +02:00

405 lines
14 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🔒 Säkerhetshärdningsplan för Recipe-App (Flutter + NestJS + MariaDB)
**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``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<Request>,
) {
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.