Add comprehensive documentation for Flutter frontend migration and backend review
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Introduced user guide for Flutter frontend in README.md, detailing user flows and recent improvements. - Created next steps roadmap for Flutter migration in next_steps_flutter.md, outlining current tasks and priorities. - Developed technical description for Flutter frontend in teknisk_beskrivning_flutter.md, covering architecture and security status. - Removed outdated migration documentation for Prisma P3009 and added recovery steps for failed migrations in migrering-MSI.md. - Established a release checklist for product launches in produktlansering.md, ensuring security and stability measures are met. - Formulated a systematic backend review and optimization plan in review_backend.md, focusing on reducing complexity and improving performance.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
# 🔒 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<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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user