From 6636f68819e13135be75191f70bffc486659d22a Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Fri, 29 May 2026 18:27:05 +0200 Subject: [PATCH] security hardening for secure sharing --- README.en.md | 34 +++++++++++++---- README.md | 34 +++++++++++++---- backend/app.py | 79 ++++++++++++++++++++++++++++++---------- package-lock.json | 4 +- package.json | 2 +- public/service-worker.js | 8 +++- src/App.tsx | 5 ++- src/evaluatorStorage.ts | 13 ++++++- src/i18n.tsx | 14 ++++--- src/integrity.ts | 6 +-- src/secureShare.test.ts | 3 +- src/secureShare.ts | 29 +++++++++++---- src/storage.ts | 13 ++++++- 13 files changed, 182 insertions(+), 62 deletions(-) diff --git a/README.en.md b/README.en.md index abe9a29..8f08c4f 100644 --- a/README.en.md +++ b/README.en.md @@ -286,16 +286,32 @@ npm run build The contents of `dist/` are then hosted as static files. -For an SPA, the web server should fall back to `index.html`, for example in Nginx: +For an SPA, the web server should fall back to `index.html`, but `/api/share` must be routed to FastAPI before fallback. Apache example: -```nginx -location / { - try_files $uri $uri/ /index.html; -} +```apache +ProxyPreserveHost On +ProxyPass /api/share/ http://127.0.0.1:8000/api/share/ +ProxyPassReverse /api/share/ http://127.0.0.1:8000/api/share/ +ProxyPass /api/share http://127.0.0.1:8000/api/share +ProxyPassReverse /api/share http://127.0.0.1:8000/api/share -location /api/share/ { - proxy_pass http://127.0.0.1:8000/api/share/; -} + + Require all granted + LimitRequestBody 3145728 + Header always set Cache-Control "no-store" + Header always set Pragma "no-cache" + Header always set Expires "0" + RequestHeader set X-Real-IP "%{REMOTE_ADDR}s" + +``` + +Recommended security headers for the vhost: + +```apache +Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests" +Header always set X-Content-Type-Options "nosniff" +Header always set Referrer-Policy "strict-origin-when-cross-origin" +Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" ``` ### Technology @@ -315,6 +331,8 @@ location /api/share/ { Arbeidspuls can still run as a purely static app if secure email sharing is not used. The public demo with **Send by email** requires the FastAPI backend, HTTPS, SPA fallback for `/s` and a reverse proxy from `/api/share` to the backend. +Active encrypted shares in SQLite should not be included in long-term backup. If the database is backed up, retention must be shorter than the share expiry or the backup must be limited to audit metadata after deletion. + --- ## Development diff --git a/README.md b/README.md index ba49157..d886c46 100644 --- a/README.md +++ b/README.md @@ -286,16 +286,32 @@ npm run build Innholdet i `dist/` hostes deretter som statiske filer. -For en SPA bør webserveren ha fallback til `index.html`, for eksempel i Nginx: +For en SPA bør webserveren ha fallback til `index.html`, men `/api/share` må rutes til FastAPI før fallback. Apache-eksempel: -```nginx -location / { - try_files $uri $uri/ /index.html; -} +```apache +ProxyPreserveHost On +ProxyPass /api/share/ http://127.0.0.1:8000/api/share/ +ProxyPassReverse /api/share/ http://127.0.0.1:8000/api/share/ +ProxyPass /api/share http://127.0.0.1:8000/api/share +ProxyPassReverse /api/share http://127.0.0.1:8000/api/share -location /api/share/ { - proxy_pass http://127.0.0.1:8000/api/share/; -} + + Require all granted + LimitRequestBody 3145728 + Header always set Cache-Control "no-store" + Header always set Pragma "no-cache" + Header always set Expires "0" + RequestHeader set X-Real-IP "%{REMOTE_ADDR}s" + +``` + +Anbefalte sikkerhetsheadere for vhosten: + +```apache +Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests" +Header always set X-Content-Type-Options "nosniff" +Header always set Referrer-Policy "strict-origin-when-cross-origin" +Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" ``` ### Teknologi @@ -315,6 +331,8 @@ location /api/share/ { Arbeidspuls kan fortsatt kjøres som ren statisk app hvis sikker e-postdeling ikke brukes. Offentlig demo med **Send via e-post** krever FastAPI-backend, HTTPS, SPA fallback for `/s` og reverse proxy fra `/api/share` til backend. +Aktive krypterte delinger i SQLite bør ikke tas med i langvarig backup. Hvis databasen backupes, må retention være kortere enn delingens utløp eller backupen må begrenses til auditmetadata etter sletting. + --- ## Utvikling diff --git a/backend/app.py b/backend/app.py index 5ca0733..78e43a9 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,13 +1,14 @@ from __future__ import annotations import base64 +import hashlib import os import re import sqlite3 from contextlib import contextmanager from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from uuid import uuid4 from fastapi import FastAPI, HTTPException, Request @@ -17,7 +18,11 @@ from pydantic import BaseModel, Field DATABASE_PATH = Path(os.environ.get("ARBEIDSPULS_SHARE_DB", "backend/share-data.sqlite3")) EXPIRY_DAYS = int(os.environ.get("ARBEIDSPULS_SHARE_EXPIRY_DAYS", "7")) MAX_PAYLOAD_BYTES = 2 * 1024 * 1024 -RATE_LIMIT_PER_HOUR = 10 +MAX_CIPHERTEXT_CHARS = 3_000_000 +CREATE_RATE_LIMIT_PER_HOUR = 10 +READ_RATE_LIMIT_PER_HOUR = 120 +CONFIRM_RATE_LIMIT_PER_HOUR = 60 +TRUSTED_PROXY_HOSTS = {"127.0.0.1", "::1", "localhost"} ALLOWED_ORIGINS = [ "https://arbeidspuls.rolfsvaag.no", "http://localhost:5173", @@ -37,9 +42,14 @@ rate_bucket: Dict[str, List[datetime]] = {} class ShareCreateRequest(BaseModel): - ciphertext: str = Field(min_length=1) - iv: str = Field(min_length=1) + ciphertext: str = Field(min_length=1, max_length=MAX_CIPHERTEXT_CHARS) + iv: str = Field(min_length=1, max_length=64) share_schema: str = Field(alias="schema", min_length=1, max_length=80) + confirm_token_hash: str = Field(min_length=32, max_length=128) + + +class ConfirmImportRequest(BaseModel): + confirm_token: Optional[str] = Field(default=None, max_length=256) class ShareCreateResponse(BaseModel): @@ -91,6 +101,22 @@ def init_db() -> None: ) """ ) + columns = {row["name"] for row in db.execute("PRAGMA table_info(active_shares)").fetchall()} + if "confirm_token_hash" not in columns: + db.execute("ALTER TABLE active_shares ADD COLUMN confirm_token_hash TEXT") + + +@app.middleware("http") +async def security_headers(request: Request, call_next): + response = await call_next(request) + if request.url.path.startswith("/api/share"): + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=()" + return response def decoded_size(value: str) -> int: @@ -123,20 +149,28 @@ def mark_deleted(db: sqlite3.Connection, export_id: str, created_at: str, reason def client_ip(request: Request) -> str: - forwarded = request.headers.get("x-forwarded-for", "") - if forwarded: - return forwarded.split(",")[0].strip() - return request.client.host if request.client else "unknown" + direct_host = request.client.host if request.client else "unknown" + if direct_host in TRUSTED_PROXY_HOSTS: + real_ip = request.headers.get("x-real-ip", "").strip() + if real_ip: + return real_ip + return direct_host -def assert_rate_limit(request: Request) -> None: +def assert_rate_limit(request: Request, action: str, limit: int) -> None: ip = client_ip(request) + bucket_key = f"{action}:{ip}" cutoff = utc_now() - timedelta(hours=1) - recent = [created for created in rate_bucket.get(ip, []) if created > cutoff] - if len(recent) >= RATE_LIMIT_PER_HOUR: + recent = [created for created in rate_bucket.get(bucket_key, []) if created > cutoff] + if len(recent) >= limit: raise HTTPException(status_code=429, detail="Rate limit exceeded.") recent.append(utc_now()) - rate_bucket[ip] = recent + rate_bucket[bucket_key] = recent + + +def token_hash(confirm_token: str) -> str: + digest = hashlib.sha256(confirm_token.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") @app.on_event("startup") @@ -148,11 +182,13 @@ def startup() -> None: @app.post("/api/share", response_model=ShareCreateResponse) def create_share(payload: ShareCreateRequest, request: Request) -> Dict[str, str]: cleanup_expired() - assert_rate_limit(request) + assert_rate_limit(request, "create", CREATE_RATE_LIMIT_PER_HOUR) if decoded_size(payload.ciphertext) > MAX_PAYLOAD_BYTES: raise HTTPException(status_code=413, detail="Payload is too large.") if decoded_size(payload.iv) != 12: raise HTTPException(status_code=400, detail="Invalid IV size.") + if not re.fullmatch(r"[A-Za-z0-9_-]+", payload.confirm_token_hash): + raise HTTPException(status_code=400, detail="Invalid confirm verifier.") now = utc_now() export_id = str(uuid4()) @@ -160,17 +196,18 @@ def create_share(payload: ShareCreateRequest, request: Request) -> Dict[str, str with connect() as db: db.execute( """ - INSERT INTO active_shares (export_id, created_at, expires_at, ciphertext, iv, schema) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO active_shares (export_id, created_at, expires_at, ciphertext, iv, schema, confirm_token_hash) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (export_id, iso(now), iso(expires_at), payload.ciphertext, payload.iv, payload.share_schema), + (export_id, iso(now), iso(expires_at), payload.ciphertext, payload.iv, payload.share_schema, payload.confirm_token_hash), ) return {"export_id": export_id, "expires_at": iso(expires_at)} @app.get("/api/share/{export_id}") -def get_share(export_id: str) -> Dict[str, Any]: +def get_share(export_id: str, request: Request) -> Dict[str, Any]: cleanup_expired() + assert_rate_limit(request, "read", READ_RATE_LIMIT_PER_HOUR) with connect() as db: row = db.execute("SELECT * FROM active_shares WHERE export_id = ?", (export_id,)).fetchone() if row: @@ -191,11 +228,15 @@ def get_share(export_id: str) -> Dict[str, Any]: @app.post("/api/share/{export_id}/confirm-import") -def confirm_import(export_id: str) -> Dict[str, str]: +def confirm_import(export_id: str, payload: ConfirmImportRequest, request: Request) -> Dict[str, str]: cleanup_expired() + assert_rate_limit(request, "confirm", CONFIRM_RATE_LIMIT_PER_HOUR) with connect() as db: - row = db.execute("SELECT export_id, created_at FROM active_shares WHERE export_id = ?", (export_id,)).fetchone() + row = db.execute("SELECT export_id, created_at, confirm_token_hash FROM active_shares WHERE export_id = ?", (export_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail="Share is not available.") + expected = row["confirm_token_hash"] + if not expected or not payload.confirm_token or len(payload.confirm_token) < 16 or token_hash(payload.confirm_token) != expected: + raise HTTPException(status_code=403, detail="Invalid confirmation proof.") mark_deleted(db, row["export_id"], row["created_at"], "imported_by_recipient") return {"status": "deleted"} diff --git a/package-lock.json b/package-lock.json index 974934b..71fe869 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arbeidspuls", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arbeidspuls", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "lucide-react": "^0.475.0", "react": "^19.0.0", diff --git a/package.json b/package.json index 97b0012..a67f171 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arbeidspuls", - "version": "1.2.0", + "version": "1.2.1", "private": true, "type": "module", "scripts": { diff --git a/public/service-worker.js b/public/service-worker.js index e8533dc..41193af 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "arbeidspuls-v8"; +const CACHE_NAME = "arbeidspuls-v9"; const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"]; self.addEventListener("install", (event) => { @@ -17,6 +17,12 @@ self.addEventListener("activate", (event) => { self.addEventListener("fetch", (event) => { if (event.request.method !== "GET") return; + const url = new URL(event.request.url); + const isSensitiveShareRoute = url.origin === self.location.origin && (url.pathname.startsWith("/api/share") || url.pathname.startsWith("/s")); + if (isSensitiveShareRoute) { + event.respondWith(fetch(event.request, { cache: "no-store" })); + return; + } event.respondWith( caches.match(event.request).then((cached) => { diff --git a/src/App.tsx b/src/App.tsx index c6ab2d1..373c441 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -115,7 +115,7 @@ type LineVisibility = "normal" | "dim" | "hidden"; type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string }; const PERIOD_KEY = "arbeidsevne-egenvurdering:trend-period"; -const APP_VERSION = "1.2"; +const APP_VERSION = "1.2.1"; const taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[]; const limitationOptions = Object.keys(limitationLabels) as Limitation[]; @@ -2240,7 +2240,8 @@ function SecureSharePage() { } const payload = await decryptSecureSharePayload(shared, request.key); const result = importExportPayload(JSON.stringify(payload), "", undefined, false); - await confirmSecureShareImport(request.exportId); + if (!request.confirmToken) throw new Error(t("share.missingConfirmToken")); + await confirmSecureShareImport(request.exportId, request.confirmToken); setStatus("done"); setMessage( t("share.imported", { diff --git a/src/evaluatorStorage.ts b/src/evaluatorStorage.ts index f04add0..3fcec49 100644 --- a/src/evaluatorStorage.ts +++ b/src/evaluatorStorage.ts @@ -6,6 +6,8 @@ import type { EvaluatorNote, EvaluatorProfile, ExportPayload, ImportTargetResolu const EVALUATOR_KEY = "arbeidsevne-egenvurdering:evaluator-profiles"; const DEMO_EVALUATOR_KEY = "arbeidspuls:demo-evaluator-profiles"; const DEMO_EVALUATOR_SEEDED_KEY = "arbeidspuls:demo-evaluator-seeded"; +const MAX_IMPORT_BYTES = 5 * 1024 * 1024; +const BLOCKED_IMPORT_KEYS = new Set(["__proto__", "constructor", "prototype"]); export type ImportResult = { profile: EvaluatorProfile; @@ -110,7 +112,7 @@ export function deleteEvaluatorNote( } export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string, demoMode = false): ImportResult { - const parsed = JSON.parse(jsonText); + const parsed = parseImportJson(jsonText); const reports = extractReports(parsed).map(verifyEntryIntegrity); const demoEntriesDetected = countDemoEntries(reports, parsed); const profiles = readProfiles(demoMode).map(normalizeProfile); @@ -189,7 +191,7 @@ export function exportEvaluatorBackup(demoMode = false) { } export function importEvaluatorBackup(jsonText: string, demoMode = false) { - const parsed = JSON.parse(jsonText); + const parsed = parseImportJson(jsonText); if (parsed?.backup_type !== "arbeidsevne-veilederdata" || !Array.isArray(parsed.profiles)) { throw new Error("Filen er ikke en gyldig veilederbackup."); } @@ -358,3 +360,10 @@ function resolveImportTarget(reports: StoredEntry[], profiles: EvaluatorProfile[ matchedReportIds: matches.flatMap((match) => match.matchedReportIds) }; } + +function parseImportJson(jsonText: string) { + if (new TextEncoder().encode(jsonText).length > MAX_IMPORT_BYTES) { + throw new Error("Importfilen er for stor."); + } + return JSON.parse(jsonText, (key, value) => (BLOCKED_IMPORT_KEYS.has(key) ? undefined : value)); +} diff --git a/src/i18n.tsx b/src/i18n.tsx index 3395171..b6a0886 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -184,7 +184,7 @@ const nb = { createEmail: "Opprett e-post", emailLinkCreated: "E-postklient er forsøkt åpnet. Bruk copy-paste-teksten hvis den ikke ble fylt ut riktig.", emailLinkFailed: "Kunne ikke opprette sikker deling.", - copyPasteBackupTitle: "Copy-paste backup", + copyPasteBackupTitle: "Manuell e-posttekst", copyPasteBackupIntro: "Hvis e-postklienten ikke åpnet eller fylte inn teksten riktig, kopier mottaker, emne og tekst manuelt.", emailSubjectLabel: "Emne", emailBodyLabel: "E-posttekst", @@ -227,6 +227,7 @@ const nb = { expired: "Denne delingen er automatisk slettet fordi den er mer enn 7 dager gammel.", requestNewShare: "Be avsenderen opprette en ny deling hvis du fortsatt trenger tilgang.", missingKey: "Lenken mangler dekrypteringsnøkkel. Be avsenderen sende hele lenken på nytt.", + missingConfirmToken: "Lenken mangler bekreftelseskode. Be avsenderen sende hele lenken på nytt.", decryptFailed: "Kunne ikke dekryptere delingen. Lenken kan være ufullstendig eller feil.", notFound: "Delingen finnes ikke.", imported: "Import fullført. Nye oppføringer: {reports}. Duplikater hoppet over: {skipped}." @@ -381,9 +382,9 @@ const nb = { veryLowShort: "Svært lav" }, verification: { - valid: "Verifisert", + valid: "Integritet OK", modified: "Mulig endret", - missing: "Ikke verifisert", + missing: "Mangler lokal hash", unsupported: "Verifisering støttes ikke", error: "Feil ved verifisering", invalidLabel: "Varsel: rapporten kunne ikke verifiseres" @@ -531,7 +532,7 @@ const en: TranslationTree = { createEmail: "Create email", emailLinkCreated: "The email client was opened if available. Use the copy-paste text if it was not filled in correctly.", emailLinkFailed: "Could not create secure share.", - copyPasteBackupTitle: "Copy-paste backup", + copyPasteBackupTitle: "Manual email text", copyPasteBackupIntro: "If the email client did not open or fill in the text correctly, copy the recipient, subject and text manually.", emailSubjectLabel: "Subject", emailBodyLabel: "Email text", @@ -573,6 +574,7 @@ const en: TranslationTree = { expired: "This share was automatically deleted because it is more than 7 days old.", requestNewShare: "Ask the sender to create a new share if you still need access.", missingKey: "The link is missing its decryption key. Ask the sender to send the full link again.", + missingConfirmToken: "The link is missing its confirmation code. Ask the sender to send the full link again.", decryptFailed: "Could not decrypt the share. The link may be incomplete or incorrect.", notFound: "The share does not exist.", imported: "Import complete. New entries: {reports}. Duplicates skipped: {skipped}." @@ -727,9 +729,9 @@ const en: TranslationTree = { veryLowShort: "Very low" }, verification: { - valid: "Verified", + valid: "Integrity OK", modified: "Possibly changed", - missing: "Not verified", + missing: "Missing local hash", unsupported: "Verification is not supported", error: "Verification error", invalidLabel: "Warning: the report could not be verified" diff --git a/src/integrity.ts b/src/integrity.ts index c5b6be3..d89b686 100644 --- a/src/integrity.ts +++ b/src/integrity.ts @@ -97,7 +97,7 @@ export function summarizeVerification(entries: StoredEntry[]): VerificationSumma export function verificationLabel(status: VerificationStatus | undefined) { switch (status) { case "valid": - return "Verifisert"; + return "Integritet OK"; case "modified": return "Mulig endret"; case "unsupported": @@ -106,7 +106,7 @@ export function verificationLabel(status: VerificationStatus | undefined) { return "Feil ved verifisering"; case "missing": default: - return "Ikke verifisert"; + return "Mangler lokal hash"; } } @@ -122,7 +122,7 @@ export function verificationTooltip(status: VerificationStatus | undefined) { case "error": return "Det oppstod en feil ved verifisering."; case "valid": - return "Rapporten matcher lagret integritetsdata."; + return "Dette er en lokal teknisk integritetskontroll. Den kan indikere om eksportdata er endret, men er ikke en garanti for autentisitet."; } } diff --git a/src/secureShare.test.ts b/src/secureShare.test.ts index 3fdc57e..e8ab164 100644 --- a/src/secureShare.test.ts +++ b/src/secureShare.test.ts @@ -5,11 +5,12 @@ describe("sikker deling", () => { it("leser export id fra /s?uuid og nøkkel fra fragment", () => { const parsed = parseShareRequestFromLocation({ search: "?123e4567-e89b-12d3-a456-426614174000", - hash: "#key=abc_def-123" + hash: "#key=abc_def-123&confirm=confirm_456" } as Location); expect(parsed.exportId).toBe("123e4567-e89b-12d3-a456-426614174000"); expect(parsed.key).toBe("abc_def-123"); + expect(parsed.confirmToken).toBe("confirm_456"); }); it("base64url roundtripper bytes uten pluss, slash eller padding", () => { diff --git a/src/secureShare.ts b/src/secureShare.ts index 4a64005..c699cf7 100644 --- a/src/secureShare.ts +++ b/src/secureShare.ts @@ -30,6 +30,7 @@ export type SecureShareLink = { export type ShareRequest = { exportId: string | null; key: string | null; + confirmToken: string | null; }; export function parseShareRequestFromLocation(location: Pick): ShareRequest { @@ -38,19 +39,24 @@ export function parseShareRequestFromLocation(location: Pick { const encrypted = await encryptJsonForShare(jsonText); + const confirmToken = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); + const confirmTokenHash = await sha256Base64Url(confirmToken); const response = await fetch("/api/share", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ciphertext: encrypted.ciphertext, iv: encrypted.iv, - schema: SHARE_SCHEMA + schema: SHARE_SCHEMA, + confirm_token_hash: confirmTokenHash }) }); if (!response.ok) throw new Error("Kunne ikke opprette sikker deling."); @@ -58,19 +64,23 @@ export async function createSecureShare(jsonText: string, origin = window.locati return { exportId: result.export_id, expiresAt: result.expires_at, - shareUrl: `${origin}/s?${encodeURIComponent(result.export_id)}#key=${encrypted.key}` + shareUrl: `${origin}/s?${encodeURIComponent(result.export_id)}#key=${encrypted.key}&confirm=${confirmToken}` }; } export async function fetchSecureShare(exportId: string): Promise { - const response = await fetch(`/api/share/${encodeURIComponent(exportId)}`); + const response = await fetch(`/api/share/${encodeURIComponent(exportId)}`, { cache: "no-store" }); if (response.status === 404) return { status: "not_found", export_id: exportId }; if (!response.ok) throw new Error("Kunne ikke hente delingen."); return await response.json() as SecureShareFetchResponse; } -export async function confirmSecureShareImport(exportId: string): Promise { - const response = await fetch(`/api/share/${encodeURIComponent(exportId)}/confirm-import`, { method: "POST" }); +export async function confirmSecureShareImport(exportId: string, confirmToken: string): Promise { + const response = await fetch(`/api/share/${encodeURIComponent(exportId)}/confirm-import`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm_token: confirmToken }) + }); if (!response.ok) throw new Error("Importen ble lagret lokalt, men delingen kunne ikke bekreftes slettet."); } @@ -112,3 +122,8 @@ export function base64UrlDecode(value: string) { for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return bytes; } + +async function sha256Base64Url(value: string) { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return base64UrlEncode(new Uint8Array(digest)); +} diff --git a/src/storage.ts b/src/storage.ts index 56fc423..7afcec1 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -7,6 +7,8 @@ const STORAGE_KEY = "arbeidsevne-egenvurdering:entries"; const DEMO_STORAGE_KEY = "arbeidspuls:demo-entries"; const DEMO_MODE_KEY = "arbeidspuls:demo-mode"; const DEMO_SEEDED_KEY = "arbeidspuls:demo-seeded"; +const MAX_IMPORT_BYTES = 5 * 1024 * 1024; +const BLOCKED_IMPORT_KEYS = new Set(["__proto__", "constructor", "prototype"]); export type DataMode = "normal" | "demo"; export type ExportRange = { @@ -308,7 +310,7 @@ function getExportEntryTime(entry: StoredEntry) { } export function previewImportReports(jsonText: string, demoMode = false) { - const parsed = JSON.parse(jsonText); + const parsed = parseImportJson(jsonText); const imported = parseImportEntriesFromParsed(parsed).map(normalizeStoredEntry).map(verifyEntryIntegrity); const demoEntriesDetected = countDemoEntries(imported, parsed); if (!demoMode && demoEntriesDetected > 0) { @@ -324,7 +326,7 @@ export function previewImportReports(jsonText: string, demoMode = false) { } export function importReportsFromJson(jsonText: string, demoMode = false): ImportSummary { - const parsed = JSON.parse(jsonText); + const parsed = parseImportJson(jsonText); const imported = parseImportEntriesFromParsed(parsed).map(normalizeStoredEntry).map(verifyEntryIntegrity); const demoEntriesDetected = countDemoEntries(imported, parsed); if (!demoMode && demoEntriesDetected > 0) { @@ -361,6 +363,13 @@ function parseImportEntriesFromParsed(parsed: unknown): StoredEntry[] { return entries; } +function parseImportJson(jsonText: string) { + if (new TextEncoder().encode(jsonText).length > MAX_IMPORT_BYTES) { + throw new Error("Importfilen er for stor."); + } + return JSON.parse(jsonText, (key, value) => (BLOCKED_IMPORT_KEYS.has(key) ? undefined : value)); +} + function summarizeImport(imported: StoredEntry[], existing: StoredEntry[]): ImportSummary { const existingSignatures = new Set(existing.map(entrySignature)); const existingById = new Map(existing.map((entry) => [entry.id, entry]));