security hardening for secure sharing

This commit is contained in:
Franz Rolfsvaag 2026-05-29 18:27:05 +02:00
parent 0094019e32
commit 6636f68819
13 changed files with 182 additions and 62 deletions

View File

@ -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/;
}
<Location "/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"
</Location>
```
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

View File

@ -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/;
}
<Location "/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"
</Location>
```
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

View File

@ -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"}

4
package-lock.json generated
View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "arbeidspuls",
"version": "1.2.0",
"version": "1.2.1",
"private": true,
"type": "module",
"scripts": {

View File

@ -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) => {

View File

@ -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", {

View File

@ -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));
}

View File

@ -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"

View File

@ -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.";
}
}

View File

@ -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", () => {

View File

@ -30,6 +30,7 @@ export type SecureShareLink = {
export type ShareRequest = {
exportId: string | null;
key: string | null;
confirmToken: string | null;
};
export function parseShareRequestFromLocation(location: Pick<Location, "search" | "hash">): ShareRequest {
@ -38,19 +39,24 @@ export function parseShareRequestFromLocation(location: Pick<Location, "search"
const positionalId = search && !search.includes("=") ? decodeURIComponent(search) : null;
const exportId = params.get("id") ?? params.get("export_id") ?? positionalId;
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
const key = new URLSearchParams(hash).get("key");
return { exportId, key };
const hashParams = new URLSearchParams(hash);
const key = hashParams.get("key");
const confirmToken = hashParams.get("confirm");
return { exportId, key, confirmToken };
}
export async function createSecureShare(jsonText: string, origin = window.location.origin): Promise<SecureShareLink> {
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<SecureShareFetchResponse> {
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<void> {
const response = await fetch(`/api/share/${encodeURIComponent(exportId)}/confirm-import`, { method: "POST" });
export async function confirmSecureShareImport(exportId: string, confirmToken: string): Promise<void> {
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));
}

View File

@ -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]));