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