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.
|
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
|
```apache
|
||||||
location / {
|
ProxyPreserveHost On
|
||||||
try_files $uri $uri/ /index.html;
|
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/ {
|
<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"
|
||||||
|
</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
|
### 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.
|
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
|
## Development
|
||||||
|
|||||||
34
README.md
34
README.md
@ -286,16 +286,32 @@ npm run build
|
|||||||
|
|
||||||
Innholdet i `dist/` hostes deretter som statiske filer.
|
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
|
```apache
|
||||||
location / {
|
ProxyPreserveHost On
|
||||||
try_files $uri $uri/ /index.html;
|
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/ {
|
<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"
|
||||||
|
</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
|
### 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.
|
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
|
## Utvikling
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
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"))
|
DATABASE_PATH = Path(os.environ.get("ARBEIDSPULS_SHARE_DB", "backend/share-data.sqlite3"))
|
||||||
EXPIRY_DAYS = int(os.environ.get("ARBEIDSPULS_SHARE_EXPIRY_DAYS", "7"))
|
EXPIRY_DAYS = int(os.environ.get("ARBEIDSPULS_SHARE_EXPIRY_DAYS", "7"))
|
||||||
MAX_PAYLOAD_BYTES = 2 * 1024 * 1024
|
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 = [
|
ALLOWED_ORIGINS = [
|
||||||
"https://arbeidspuls.rolfsvaag.no",
|
"https://arbeidspuls.rolfsvaag.no",
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
@ -37,9 +42,14 @@ rate_bucket: Dict[str, List[datetime]] = {}
|
|||||||
|
|
||||||
|
|
||||||
class ShareCreateRequest(BaseModel):
|
class ShareCreateRequest(BaseModel):
|
||||||
ciphertext: str = Field(min_length=1)
|
ciphertext: str = Field(min_length=1, max_length=MAX_CIPHERTEXT_CHARS)
|
||||||
iv: str = Field(min_length=1)
|
iv: str = Field(min_length=1, max_length=64)
|
||||||
share_schema: str = Field(alias="schema", min_length=1, max_length=80)
|
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):
|
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:
|
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:
|
def client_ip(request: Request) -> str:
|
||||||
forwarded = request.headers.get("x-forwarded-for", "")
|
direct_host = request.client.host if request.client else "unknown"
|
||||||
if forwarded:
|
if direct_host in TRUSTED_PROXY_HOSTS:
|
||||||
return forwarded.split(",")[0].strip()
|
real_ip = request.headers.get("x-real-ip", "").strip()
|
||||||
return request.client.host if request.client else "unknown"
|
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)
|
ip = client_ip(request)
|
||||||
|
bucket_key = f"{action}:{ip}"
|
||||||
cutoff = utc_now() - timedelta(hours=1)
|
cutoff = utc_now() - timedelta(hours=1)
|
||||||
recent = [created for created in rate_bucket.get(ip, []) if created > cutoff]
|
recent = [created for created in rate_bucket.get(bucket_key, []) if created > cutoff]
|
||||||
if len(recent) >= RATE_LIMIT_PER_HOUR:
|
if len(recent) >= limit:
|
||||||
raise HTTPException(status_code=429, detail="Rate limit exceeded.")
|
raise HTTPException(status_code=429, detail="Rate limit exceeded.")
|
||||||
recent.append(utc_now())
|
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")
|
@app.on_event("startup")
|
||||||
@ -148,11 +182,13 @@ def startup() -> None:
|
|||||||
@app.post("/api/share", response_model=ShareCreateResponse)
|
@app.post("/api/share", response_model=ShareCreateResponse)
|
||||||
def create_share(payload: ShareCreateRequest, request: Request) -> Dict[str, str]:
|
def create_share(payload: ShareCreateRequest, request: Request) -> Dict[str, str]:
|
||||||
cleanup_expired()
|
cleanup_expired()
|
||||||
assert_rate_limit(request)
|
assert_rate_limit(request, "create", CREATE_RATE_LIMIT_PER_HOUR)
|
||||||
if decoded_size(payload.ciphertext) > MAX_PAYLOAD_BYTES:
|
if decoded_size(payload.ciphertext) > MAX_PAYLOAD_BYTES:
|
||||||
raise HTTPException(status_code=413, detail="Payload is too large.")
|
raise HTTPException(status_code=413, detail="Payload is too large.")
|
||||||
if decoded_size(payload.iv) != 12:
|
if decoded_size(payload.iv) != 12:
|
||||||
raise HTTPException(status_code=400, detail="Invalid IV size.")
|
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()
|
now = utc_now()
|
||||||
export_id = str(uuid4())
|
export_id = str(uuid4())
|
||||||
@ -160,17 +196,18 @@ def create_share(payload: ShareCreateRequest, request: Request) -> Dict[str, str
|
|||||||
with connect() as db:
|
with connect() as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO active_shares (export_id, created_at, expires_at, ciphertext, iv, schema)
|
INSERT INTO active_shares (export_id, created_at, expires_at, ciphertext, iv, schema, confirm_token_hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
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)}
|
return {"export_id": export_id, "expires_at": iso(expires_at)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/share/{export_id}")
|
@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()
|
cleanup_expired()
|
||||||
|
assert_rate_limit(request, "read", READ_RATE_LIMIT_PER_HOUR)
|
||||||
with connect() as db:
|
with connect() as db:
|
||||||
row = db.execute("SELECT * FROM active_shares WHERE export_id = ?", (export_id,)).fetchone()
|
row = db.execute("SELECT * FROM active_shares WHERE export_id = ?", (export_id,)).fetchone()
|
||||||
if row:
|
if row:
|
||||||
@ -191,11 +228,15 @@ def get_share(export_id: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/share/{export_id}/confirm-import")
|
@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()
|
cleanup_expired()
|
||||||
|
assert_rate_limit(request, "confirm", CONFIRM_RATE_LIMIT_PER_HOUR)
|
||||||
with connect() as db:
|
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:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Share is not available.")
|
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")
|
mark_deleted(db, row["export_id"], row["created_at"], "imported_by_recipient")
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "arbeidspuls-v8";
|
const CACHE_NAME = "arbeidspuls-v9";
|
||||||
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
@ -17,6 +17,12 @@ self.addEventListener("activate", (event) => {
|
|||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
if (event.request.method !== "GET") return;
|
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(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cached) => {
|
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 };
|
type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string };
|
||||||
|
|
||||||
const PERIOD_KEY = "arbeidsevne-egenvurdering:trend-period";
|
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 taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[];
|
||||||
const limitationOptions = Object.keys(limitationLabels) as Limitation[];
|
const limitationOptions = Object.keys(limitationLabels) as Limitation[];
|
||||||
@ -2240,7 +2240,8 @@ function SecureSharePage() {
|
|||||||
}
|
}
|
||||||
const payload = await decryptSecureSharePayload(shared, request.key);
|
const payload = await decryptSecureSharePayload(shared, request.key);
|
||||||
const result = importExportPayload(JSON.stringify(payload), "", undefined, false);
|
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");
|
setStatus("done");
|
||||||
setMessage(
|
setMessage(
|
||||||
t("share.imported", {
|
t("share.imported", {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import type { EvaluatorNote, EvaluatorProfile, ExportPayload, ImportTargetResolu
|
|||||||
const EVALUATOR_KEY = "arbeidsevne-egenvurdering:evaluator-profiles";
|
const EVALUATOR_KEY = "arbeidsevne-egenvurdering:evaluator-profiles";
|
||||||
const DEMO_EVALUATOR_KEY = "arbeidspuls:demo-evaluator-profiles";
|
const DEMO_EVALUATOR_KEY = "arbeidspuls:demo-evaluator-profiles";
|
||||||
const DEMO_EVALUATOR_SEEDED_KEY = "arbeidspuls:demo-evaluator-seeded";
|
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 = {
|
export type ImportResult = {
|
||||||
profile: EvaluatorProfile;
|
profile: EvaluatorProfile;
|
||||||
@ -110,7 +112,7 @@ export function deleteEvaluatorNote(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string, demoMode = false): ImportResult {
|
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 reports = extractReports(parsed).map(verifyEntryIntegrity);
|
||||||
const demoEntriesDetected = countDemoEntries(reports, parsed);
|
const demoEntriesDetected = countDemoEntries(reports, parsed);
|
||||||
const profiles = readProfiles(demoMode).map(normalizeProfile);
|
const profiles = readProfiles(demoMode).map(normalizeProfile);
|
||||||
@ -189,7 +191,7 @@ export function exportEvaluatorBackup(demoMode = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function importEvaluatorBackup(jsonText: string, 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)) {
|
if (parsed?.backup_type !== "arbeidsevne-veilederdata" || !Array.isArray(parsed.profiles)) {
|
||||||
throw new Error("Filen er ikke en gyldig veilederbackup.");
|
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)
|
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",
|
createEmail: "Opprett e-post",
|
||||||
emailLinkCreated: "E-postklient er forsøkt åpnet. Bruk copy-paste-teksten hvis den ikke ble fylt ut riktig.",
|
emailLinkCreated: "E-postklient er forsøkt åpnet. Bruk copy-paste-teksten hvis den ikke ble fylt ut riktig.",
|
||||||
emailLinkFailed: "Kunne ikke opprette sikker deling.",
|
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.",
|
copyPasteBackupIntro: "Hvis e-postklienten ikke åpnet eller fylte inn teksten riktig, kopier mottaker, emne og tekst manuelt.",
|
||||||
emailSubjectLabel: "Emne",
|
emailSubjectLabel: "Emne",
|
||||||
emailBodyLabel: "E-posttekst",
|
emailBodyLabel: "E-posttekst",
|
||||||
@ -227,6 +227,7 @@ const nb = {
|
|||||||
expired: "Denne delingen er automatisk slettet fordi den er mer enn 7 dager gammel.",
|
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.",
|
requestNewShare: "Be avsenderen opprette en ny deling hvis du fortsatt trenger tilgang.",
|
||||||
missingKey: "Lenken mangler dekrypteringsnøkkel. Be avsenderen sende hele lenken på nytt.",
|
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.",
|
decryptFailed: "Kunne ikke dekryptere delingen. Lenken kan være ufullstendig eller feil.",
|
||||||
notFound: "Delingen finnes ikke.",
|
notFound: "Delingen finnes ikke.",
|
||||||
imported: "Import fullført. Nye oppføringer: {reports}. Duplikater hoppet over: {skipped}."
|
imported: "Import fullført. Nye oppføringer: {reports}. Duplikater hoppet over: {skipped}."
|
||||||
@ -381,9 +382,9 @@ const nb = {
|
|||||||
veryLowShort: "Svært lav"
|
veryLowShort: "Svært lav"
|
||||||
},
|
},
|
||||||
verification: {
|
verification: {
|
||||||
valid: "Verifisert",
|
valid: "Integritet OK",
|
||||||
modified: "Mulig endret",
|
modified: "Mulig endret",
|
||||||
missing: "Ikke verifisert",
|
missing: "Mangler lokal hash",
|
||||||
unsupported: "Verifisering støttes ikke",
|
unsupported: "Verifisering støttes ikke",
|
||||||
error: "Feil ved verifisering",
|
error: "Feil ved verifisering",
|
||||||
invalidLabel: "Varsel: rapporten kunne ikke verifiseres"
|
invalidLabel: "Varsel: rapporten kunne ikke verifiseres"
|
||||||
@ -531,7 +532,7 @@ const en: TranslationTree = {
|
|||||||
createEmail: "Create email",
|
createEmail: "Create email",
|
||||||
emailLinkCreated: "The email client was opened if available. Use the copy-paste text if it was not filled in correctly.",
|
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.",
|
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.",
|
copyPasteBackupIntro: "If the email client did not open or fill in the text correctly, copy the recipient, subject and text manually.",
|
||||||
emailSubjectLabel: "Subject",
|
emailSubjectLabel: "Subject",
|
||||||
emailBodyLabel: "Email text",
|
emailBodyLabel: "Email text",
|
||||||
@ -573,6 +574,7 @@ const en: TranslationTree = {
|
|||||||
expired: "This share was automatically deleted because it is more than 7 days old.",
|
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.",
|
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.",
|
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.",
|
decryptFailed: "Could not decrypt the share. The link may be incomplete or incorrect.",
|
||||||
notFound: "The share does not exist.",
|
notFound: "The share does not exist.",
|
||||||
imported: "Import complete. New entries: {reports}. Duplicates skipped: {skipped}."
|
imported: "Import complete. New entries: {reports}. Duplicates skipped: {skipped}."
|
||||||
@ -727,9 +729,9 @@ const en: TranslationTree = {
|
|||||||
veryLowShort: "Very low"
|
veryLowShort: "Very low"
|
||||||
},
|
},
|
||||||
verification: {
|
verification: {
|
||||||
valid: "Verified",
|
valid: "Integrity OK",
|
||||||
modified: "Possibly changed",
|
modified: "Possibly changed",
|
||||||
missing: "Not verified",
|
missing: "Missing local hash",
|
||||||
unsupported: "Verification is not supported",
|
unsupported: "Verification is not supported",
|
||||||
error: "Verification error",
|
error: "Verification error",
|
||||||
invalidLabel: "Warning: the report could not be verified"
|
invalidLabel: "Warning: the report could not be verified"
|
||||||
|
|||||||
@ -97,7 +97,7 @@ export function summarizeVerification(entries: StoredEntry[]): VerificationSumma
|
|||||||
export function verificationLabel(status: VerificationStatus | undefined) {
|
export function verificationLabel(status: VerificationStatus | undefined) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "valid":
|
case "valid":
|
||||||
return "Verifisert";
|
return "Integritet OK";
|
||||||
case "modified":
|
case "modified":
|
||||||
return "Mulig endret";
|
return "Mulig endret";
|
||||||
case "unsupported":
|
case "unsupported":
|
||||||
@ -106,7 +106,7 @@ export function verificationLabel(status: VerificationStatus | undefined) {
|
|||||||
return "Feil ved verifisering";
|
return "Feil ved verifisering";
|
||||||
case "missing":
|
case "missing":
|
||||||
default:
|
default:
|
||||||
return "Ikke verifisert";
|
return "Mangler lokal hash";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +122,7 @@ export function verificationTooltip(status: VerificationStatus | undefined) {
|
|||||||
case "error":
|
case "error":
|
||||||
return "Det oppstod en feil ved verifisering.";
|
return "Det oppstod en feil ved verifisering.";
|
||||||
case "valid":
|
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", () => {
|
it("leser export id fra /s?uuid og nøkkel fra fragment", () => {
|
||||||
const parsed = parseShareRequestFromLocation({
|
const parsed = parseShareRequestFromLocation({
|
||||||
search: "?123e4567-e89b-12d3-a456-426614174000",
|
search: "?123e4567-e89b-12d3-a456-426614174000",
|
||||||
hash: "#key=abc_def-123"
|
hash: "#key=abc_def-123&confirm=confirm_456"
|
||||||
} as Location);
|
} as Location);
|
||||||
|
|
||||||
expect(parsed.exportId).toBe("123e4567-e89b-12d3-a456-426614174000");
|
expect(parsed.exportId).toBe("123e4567-e89b-12d3-a456-426614174000");
|
||||||
expect(parsed.key).toBe("abc_def-123");
|
expect(parsed.key).toBe("abc_def-123");
|
||||||
|
expect(parsed.confirmToken).toBe("confirm_456");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("base64url roundtripper bytes uten pluss, slash eller padding", () => {
|
it("base64url roundtripper bytes uten pluss, slash eller padding", () => {
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export type SecureShareLink = {
|
|||||||
export type ShareRequest = {
|
export type ShareRequest = {
|
||||||
exportId: string | null;
|
exportId: string | null;
|
||||||
key: string | null;
|
key: string | null;
|
||||||
|
confirmToken: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseShareRequestFromLocation(location: Pick<Location, "search" | "hash">): ShareRequest {
|
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 positionalId = search && !search.includes("=") ? decodeURIComponent(search) : null;
|
||||||
const exportId = params.get("id") ?? params.get("export_id") ?? positionalId;
|
const exportId = params.get("id") ?? params.get("export_id") ?? positionalId;
|
||||||
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
|
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
|
||||||
const key = new URLSearchParams(hash).get("key");
|
const hashParams = new URLSearchParams(hash);
|
||||||
return { exportId, key };
|
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> {
|
export async function createSecureShare(jsonText: string, origin = window.location.origin): Promise<SecureShareLink> {
|
||||||
const encrypted = await encryptJsonForShare(jsonText);
|
const encrypted = await encryptJsonForShare(jsonText);
|
||||||
|
const confirmToken = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
||||||
|
const confirmTokenHash = await sha256Base64Url(confirmToken);
|
||||||
const response = await fetch("/api/share", {
|
const response = await fetch("/api/share", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ciphertext: encrypted.ciphertext,
|
ciphertext: encrypted.ciphertext,
|
||||||
iv: encrypted.iv,
|
iv: encrypted.iv,
|
||||||
schema: SHARE_SCHEMA
|
schema: SHARE_SCHEMA,
|
||||||
|
confirm_token_hash: confirmTokenHash
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error("Kunne ikke opprette sikker deling.");
|
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 {
|
return {
|
||||||
exportId: result.export_id,
|
exportId: result.export_id,
|
||||||
expiresAt: result.expires_at,
|
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> {
|
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.status === 404) return { status: "not_found", export_id: exportId };
|
||||||
if (!response.ok) throw new Error("Kunne ikke hente delingen.");
|
if (!response.ok) throw new Error("Kunne ikke hente delingen.");
|
||||||
return await response.json() as SecureShareFetchResponse;
|
return await response.json() as SecureShareFetchResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmSecureShareImport(exportId: string): Promise<void> {
|
export async function confirmSecureShareImport(exportId: string, confirmToken: string): Promise<void> {
|
||||||
const response = await fetch(`/api/share/${encodeURIComponent(exportId)}/confirm-import`, { method: "POST" });
|
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.");
|
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);
|
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||||
return bytes;
|
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_STORAGE_KEY = "arbeidspuls:demo-entries";
|
||||||
const DEMO_MODE_KEY = "arbeidspuls:demo-mode";
|
const DEMO_MODE_KEY = "arbeidspuls:demo-mode";
|
||||||
const DEMO_SEEDED_KEY = "arbeidspuls:demo-seeded";
|
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 DataMode = "normal" | "demo";
|
||||||
export type ExportRange = {
|
export type ExportRange = {
|
||||||
@ -308,7 +310,7 @@ function getExportEntryTime(entry: StoredEntry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function previewImportReports(jsonText: string, demoMode = false) {
|
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 imported = parseImportEntriesFromParsed(parsed).map(normalizeStoredEntry).map(verifyEntryIntegrity);
|
||||||
const demoEntriesDetected = countDemoEntries(imported, parsed);
|
const demoEntriesDetected = countDemoEntries(imported, parsed);
|
||||||
if (!demoMode && demoEntriesDetected > 0) {
|
if (!demoMode && demoEntriesDetected > 0) {
|
||||||
@ -324,7 +326,7 @@ export function previewImportReports(jsonText: string, demoMode = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function importReportsFromJson(jsonText: string, demoMode = false): ImportSummary {
|
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 imported = parseImportEntriesFromParsed(parsed).map(normalizeStoredEntry).map(verifyEntryIntegrity);
|
||||||
const demoEntriesDetected = countDemoEntries(imported, parsed);
|
const demoEntriesDetected = countDemoEntries(imported, parsed);
|
||||||
if (!demoMode && demoEntriesDetected > 0) {
|
if (!demoMode && demoEntriesDetected > 0) {
|
||||||
@ -361,6 +363,13 @@ function parseImportEntriesFromParsed(parsed: unknown): StoredEntry[] {
|
|||||||
return entries;
|
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 {
|
function summarizeImport(imported: StoredEntry[], existing: StoredEntry[]): ImportSummary {
|
||||||
const existingSignatures = new Set(existing.map(entrySignature));
|
const existingSignatures = new Set(existing.map(entrySignature));
|
||||||
const existingById = new Map(existing.map((entry) => [entry.id, entry]));
|
const existingById = new Map(existing.map((entry) => [entry.id, entry]));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user