security hardening for secure sharing
This commit is contained in:
parent
0094019e32
commit
6636f68819
34
README.en.md
34
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/;
|
||||
}
|
||||
<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
|
||||
|
||||
34
README.md
34
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/;
|
||||
}
|
||||
<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
|
||||
|
||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "arbeidspuls",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
14
src/i18n.tsx
14
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"
|
||||
|
||||
@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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]));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user