- Added detailed security status section in backend documentation, outlining implemented security features and remaining risks as of 2026-05-07. - Included security features implemented in Flutter, emphasizing auth-gating, token storage, and limitations regarding web security.
14 KiB
🔒 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 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: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 ischema.prismasaknaruserId-kolumninventory.controller.tshar noll anrop till@CurrentUser()inventory.service.tsmetoderfindAll(),findExpiring(),update(),remove()tar inte inuserId
Lösning — 3 steg:
Steg 1: Lägg till userId i Prisma-schemat
// 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:
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
// 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
// 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
// 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å):
# Kompilerat backend-output — byggs i Docker, ska ej spåras
backend/dist/
backend/tsconfig.tsbuildinfo
Rensa sedan från git-historiken (enbart lokal tracking):
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:
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:
.env
.env.*
!.env.example
Skapa .env.example (finns ej i repot):
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:
- Backend sätter token som httpOnly cookie vid login — Flutter Web läser aldrig token direkt
- 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_storagefungerar 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:
// 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 beroendennpx prisma validate— verifiera schema-integritet- Lägg till
gitleakssom pre-commit hook lokalt (körs inte i Docker-build)
# 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 | ⬜ CRITICAL | Backend (NestJS/Prisma) |
dist/ tillagd i .gitignore |
⬜ HIGH | Git |
.env (utan suffix) i .gitignore + .env.example |
⬜ MEDIUM | 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 |
⬜ MEDIUM | CI/CD (Gitea) |
🎯 8. Prioriterad Ordning för Implementering
- Inventory IDOR + userId-fält (CRITICAL) — påverkar alla användares data
- dist/ i .gitignore (HIGH) — förhindrar återkommande deploy-problem
- bare .env + .env.example (MEDIUM)
- npm audit i CI (MEDIUM)
- Flutter httpOnly cookies (LOW — kräver arkitekturförändring)
- Gitea webhook-validering (LOW — bara relevant om webhooks används)