Files
recipe-app/Säkerhetshärdningsplan för Recipe-app.md
T

15 KiB
Raw Blame History

🔒 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 (ownerIdProduct) 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 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

// 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:

  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 (ITokenStorageWebTokenStorage). 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:

// 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)
# 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.