Resolve security audit findings
ober
7c4cdf6c1f89848a9034193e2a59b9ef87121077
new file mode 100644 --- /dev/null +++ b/.jerboa/security.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "repo": "jerboa-imagesite", + "extends": ["jerboa:daemon", "jerboa:network-service", "jerboa:web-app"], + "paths": { + "production": ["src/**/*.ss", "public/**", "support/*.c", "Makefile", ".jerbuild"], + "tests": ["tests/**", "scripts/web-security-smoke.sh"], + "generated": ["build/**", "dist/**", ".jerboa/native/**", "var/**", "tools/__pycache__/**"], + "vendor": ["public/vendor/**"], + "docs": ["README.md", "SECURITY.md", "docs/**", "deploy/**"] + }, + "policy": { + "failOn": ["critical", "high"], + "authentication": { + "urlCredentials": "deny", + "browserSession": "opaque-random-server-side", + "adminLifetimeSeconds": 3600, + "cookie": "Secure; HttpOnly; SameSite=Strict", + "failedAttemptLimit": "5 per peer per 300 seconds" + }, + "csrf": { + "mutations": "POST only", + "browserControl": "session-bound synchronizer token plus Origin/Referer validation", + "headerBearerControl": "non-simple X-Imagesite-Admin-Token header" + }, + "filesystem": { + "mediaTraversal": "descriptor-relative openat/fstatat with O_NOFOLLOW", + "symlinks": "reject", + "mountBoundary": "same st_dev as retained root", + "visitedInodes": true, + "maxDepth": 32, + "maxEntries": 100000, + "maxFileBytes": 8589934592, + "maxTotalBytes": 274877906944 + }, + "logging": { + "format": "bounded JSON lines", + "queryValues": "redacted", + "controlCharacters": "replaced", + "forwardingHeaders": "ignored unless transport peer is explicitly trusted" + }, + "supplyChain": { + "standaloneManifest": ".jerbuild", + "nativeSymbols": "support/ffi-symbols.list", + "effectiveGraphEvidence": "make sbom", + "dirtyDependencyPolicy": "release blocked" + }, + "releaseEvidence": { + "target": "make release-evidence", + "directory": "dist/release-evidence", + "requiredResult": "local_release_status=pass", + "productionPromotion": "requires a clean committed Imagesite/Sinatra/core snapshot and target-host proof" + } + }, + "suppressions": [] +} new file mode 100644 --- /dev/null +++ b/.jerbuild @@ -0,0 +1,15 @@ +;; Reproducible standalone imagesite build. Project dependencies are staged by +;; `make build`; the bundled Jerboa stdlib/native archive supplies the runtime. +(entry "src/app.ss") +(output "dist/jerboa-imagesite") +(libdirs "build/lib" + "build/test-lib" + "../jerboa-sinatra/build/lib" + "../jerboa/vendor/jsqlite/src") +(static-native #t) +(extra-sources "support/imagesite-securefs.c") +(ffi-symbols "support/ffi-symbols.list") +(os-libs + (Darwin "-lm -lpthread -lncurses -liconv -lc++") + (Linux "-lm -ldl -lpthread -lncurses -lstdc++") + (FreeBSD "-lm -lpthread -lncurses -lstdc++")) --- a/Makefile +++ b/Makefile @@ -8,26 +8,44 @@ JERBOA_HOME := $(HOME)/mine/jerboa endif SINATRA_DIR ?= $(abspath $(CURDIR)/../jerboa-sinatra) -JSQLITE_DIR ?= $(abspath $(CURDIR)/../jerboa-sqlite) +JSQLITE_SRC ?= $(JERBOA_SOURCE_HOME)/vendor/jsqlite/src BUILD_DIR ?= build SRC_STAGE := $(BUILD_DIR)/src TEST_STAGE := $(BUILD_DIR)/tests LIB_STAGE := $(BUILD_DIR)/lib TEST_LIB_STAGE := $(BUILD_DIR)/test-lib -LIBDIRS := $(LIB_STAGE):$(TEST_LIB_STAGE):$(SINATRA_DIR)/build/lib:$(JSQLITE_DIR)/lib:$(JERBOA_HOME)/lib -SQLITE_LIB_PATH := $(JSQLITE_DIR) +LIBDIRS := $(LIB_STAGE):$(TEST_LIB_STAGE):$(SINATRA_DIR)/build/lib:$(JERBOA_HOME)/lib:$(JSQLITE_SRC) UNAME_S := $(shell uname -s) -SQLITE_PREFIX ?= $(shell if [ "$(UNAME_S)" = "Darwin" ]; then brew --prefix sqlite 2>/dev/null || echo /opt/homebrew/opt/sqlite; fi) -SQLITE_LIBDIR ?= $(if $(SQLITE_PREFIX),$(SQLITE_PREFIX)/lib,) ENTRY ?= src/app.ss BINARY_OUTPUT ?= dist/jerboa-imagesite +BINARY_OBJ_DIR ?= $(abspath $(BUILD_DIR)/jerbuild-binary-imagesite) +STATIC_BINARY_OUTPUT ?= dist/jerboa-imagesite-linux-amd64 +STATIC_OBJ_DIR ?= $(abspath $(BUILD_DIR)/jerbuild-binary-imagesite-static) BINARY_NATIVE_LDFLAGS ?= $(if $(filter Darwin,$(UNAME_S)),-lc++,) +NATIVE_A := $(JERBOA_HOME)/jerboa-native-rs/target/release/libjerboa_native.a +NATIVE_SOURCES := $(JERBOA_HOME)/jerboa-native-rs/Cargo.toml $(JERBOA_HOME)/jerboa-native-rs/Cargo.lock $(wildcard $(JERBOA_HOME)/jerboa-native-rs/src/*.rs) +SECUREFS_DIR := $(CURDIR)/.jerboa/native +SECUREFS_LIB := $(SECUREFS_DIR)/$(if $(filter Darwin,$(UNAME_S)),libimagesite_securefs.dylib,libimagesite_securefs.so) +SECUREFS_ENV := IMAGESITE_DEV_NATIVE=1 IMAGESITE_SECUREFS_LIB="$(SECUREFS_LIB)" +FACE_PYTHON ?= $(HOME)/.cache/jerboa-imagesite-face-ai-py312/bin/python +FACE_MEDIA_ROOT ?= var/media +FACE_OUTPUT_ROOT ?= var/metadata/faces +FACE_LIMIT ?= 0 +FACE_LIMIT_ARG := $(if $(filter-out 0,$(FACE_LIMIT)),--limit $(FACE_LIMIT),) +FACE_ARGS ?= +STATIC_TARGET_MACHINE ?= ta6le +STATIC_RUST_TARGET ?= x86_64-unknown-linux-musl +STATIC_CHEZ_PREFIX ?= $(JERBOA_SOURCE_HOME)/.chez-cross-$(STATIC_TARGET_MACHINE) +STATIC_CSV_DIR ?= $(firstword $(wildcard $(STATIC_CHEZ_PREFIX)/lib/csv*/$(STATIC_TARGET_MACHINE))) +STATIC_XPATCH ?= $(JERBOA_SOURCE_HOME)/build/chez/xc-$(STATIC_TARGET_MACHINE)/s/xpatch +STATIC_CC ?= x86_64-linux-musl-gcc +STATIC_OS_LIBS ?= -lm -ldl -lpthread -static -no-pie -lstdc++ +STATIC_NATIVE_A ?= $(JERBOA_SOURCE_HOME)/jerboa-native-rs/target/$(STATIC_RUST_TARGET)/release/libjerboa_native.a -.PHONY: deps build test run binary import-check clean distclean +.PHONY: deps build test run binary native-runtime static-binary securefs-shim web-security-smoke security audit sbom reproducibility-report target-evidence target-evidence-selftest verify release-evidence face-clusters import-check clean distclean deps: $(MAKE) -C $(SINATRA_DIR) build - $(MAKE) -C $(JSQLITE_DIR) build build: deps rm -rf $(SRC_STAGE) $(TEST_STAGE) $(LIB_STAGE) $(TEST_LIB_STAGE) @@ -37,32 +55,77 @@ build: deps $(JERBUILD) transpile $(SRC_STAGE) $(LIB_STAGE) --force $(JERBUILD) transpile $(TEST_STAGE) $(TEST_LIB_STAGE) --force -test: build - JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \ - DYLD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$DYLD_LIBRARY_PATH" \ - LD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$LD_LIBRARY_PATH" \ - $(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/test-runner.ss +securefs-shim: + @mkdir -p "$(SECUREFS_DIR)" + @case "$(UNAME_S)" in \ + Darwin) $(CC) -dynamiclib -fPIC -O2 -Wall -Wextra -Werror -o "$(SECUREFS_LIB)" support/imagesite-securefs.c ;; \ + *) $(CC) -shared -fPIC -O2 -Wall -Wextra -Werror -o "$(SECUREFS_LIB)" support/imagesite-securefs.c ;; \ + esac -run: build - JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \ - DYLD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$DYLD_LIBRARY_PATH" \ - LD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$LD_LIBRARY_PATH" \ - $(JERBUILD) exec --libdirs "$(LIBDIRS)" $(ENTRY) +test: build securefs-shim + $(SECUREFS_ENV) $(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/test-runner.ss -binary: build +run: build securefs-shim + $(SECUREFS_ENV) $(JERBUILD) exec --libdirs "$(LIBDIRS)" $(ENTRY) + +native-runtime: $(NATIVE_A) + +$(NATIVE_A): $(NATIVE_SOURCES) + @command -v cargo >/dev/null 2>&1 || { echo "ERROR: cargo is required for the standalone TLS runtime" >&2; exit 1; } + cargo build --locked --release --manifest-path "$(JERBOA_HOME)/jerboa-native-rs/Cargo.toml" --no-default-features --features tls + +binary: build native-runtime mkdir -p $(dir $(BINARY_OUTPUT)) + JERBOA_HOME="$(JERBOA_HOME)" JERBUILD_BINARY_OBJ_DIR="$(BINARY_OBJ_DIR)" $(JERBUILD) build --config .jerbuild + +web-security-smoke: binary + @sh scripts/web-security-smoke.sh + +security: + @sh scripts/security-check.sh + +audit: + @command -v cargo-audit >/dev/null 2>&1 || command -v cargo >/dev/null 2>&1 || { echo "ERROR: cargo-audit is required" >&2; exit 1; } + cargo audit --file "$(JERBOA_HOME)/jerboa-native-rs/Cargo.lock" --deny warnings + +sbom: binary + @JERBUILD="$(JERBUILD)" JERBUILD_TOOL="$(JERBOA)" SINATRA_DIR="$(SINATRA_DIR)" JSQLITE_SRC="$(JSQLITE_SRC)" sh scripts/sbom.sh + +reproducibility-report: + @sh scripts/reproducibility-report.sh + +target-evidence: + @sh scripts/target-evidence.sh + +target-evidence-selftest: + @sh scripts/test-target-evidence.sh + +verify: security audit test binary web-security-smoke reproducibility-report sbom target-evidence-selftest target-evidence + +release-evidence: + @sh scripts/release-evidence.sh + +static-binary: build + @test -n "$(STATIC_CSV_DIR)" && test -f "$(STATIC_CSV_DIR)/libkernel.a" || { echo "ERROR: static runtime libkernel not found under $(STATIC_CHEZ_PREFIX); override STATIC_CHEZ_PREFIX or build the cross runtime first" >&2; exit 1; } + @test -f "$(STATIC_XPATCH)" || { echo "ERROR: xpatch not found at $(STATIC_XPATCH); override STATIC_XPATCH or build the cross Chez first" >&2; exit 1; } + @command -v "$(STATIC_CC)" >/dev/null 2>&1 || { echo "ERROR: $(STATIC_CC) not on PATH" >&2; exit 1; } + @test -f "$(STATIC_NATIVE_A)" || { echo "ERROR: libjerboa_native.a not found at $(STATIC_NATIVE_A); run make -C $(JERBOA_SOURCE_HOME) native-cross for $(STATIC_RUST_TARGET)" >&2; exit 1; } + mkdir -p $(dir $(STATIC_BINARY_OUTPUT)) JERBOA_HOME="$(JERBOA_HOME)" \ - JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \ - $(JERBUILD) binary --static-native $(foreach flag,$(BINARY_NATIVE_LDFLAGS),--extra-ldflag $(flag)) --libdirs "$(LIBDIRS)" $(ENTRY) $(BINARY_OUTPUT) + JERBOA_NATIVE_A="$(STATIC_NATIVE_A)" \ + JERBUILD_BINARY_OBJ_DIR="$(STATIC_OBJ_DIR)" \ + $(JERBUILD) binary --static-native --extra-source support/imagesite-securefs.c --main-c support/imagesite-main.c --cc "$(STATIC_CC)" --rust-target "$(STATIC_RUST_TARGET)" --csv-dir "$(STATIC_CSV_DIR)" --xpatch "$(STATIC_XPATCH)" --os-libs "$(STATIC_OS_LIBS)" --libdirs "$(LIBDIRS)" $(ENTRY) $(STATIC_BINARY_OUTPUT) + +face-clusters: + @test -x "$(FACE_PYTHON)" || { echo "ERROR: FACE_PYTHON not executable: $(FACE_PYTHON)" >&2; exit 1; } + "$(FACE_PYTHON)" tools/face_cluster.py --media-root "$(FACE_MEDIA_ROOT)" --output-root "$(FACE_OUTPUT_ROOT)" $(FACE_LIMIT_ARG) $(FACE_ARGS) -import-check: build - JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \ - DYLD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$DYLD_LIBRARY_PATH" \ - LD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$LD_LIBRARY_PATH" \ - $(JERBUILD) exec --libdirs "$(LIBDIRS)" support/import-check.ss +import-check: build securefs-shim + $(SECUREFS_ENV) $(JERBUILD) exec --libdirs "$(LIBDIRS)" support/import-check.ss clean: rm -rf $(BUILD_DIR) + rm -rf $(SECUREFS_DIR) distclean: clean rm -rf dist var --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ The design keeps the durable archive simple: - originals and videos stay on disk under a media root; - generated thumbnails/previews are optional files under a derivative root; -- `jerboa-sqlite` stores albums, media rows, tags, captions, visibility, and - sync history; +- Jerboa's `(std db sqlite)` API, backed by vendored jsqlite, stores albums, + media rows, tags, captions, visibility, and sync history; - Jerboa-Sinatra serves the catalog UI, search, JSON endpoints, family access gate, and admin sync hooks; -- nginx should serve large `/media/` and `/derivatives/` files directly in - production. +- the app can terminate TLS directly and serves gated media with byte-range + support for video playback. This is intentionally not a PHP plugin system. It takes the useful Piwigo ideas that mattered in the existing deployment, physical albums, tags, search, @@ -25,27 +25,53 @@ keeping the runtime native Scheme plus SQLite. make build ``` -The Makefile builds sibling dependencies from `../jerboa-sinatra` and -`../jerboa-sqlite`, then transpiles this repository into `build/lib`. +The Makefile builds sibling dependencies from `../jerboa-sinatra`, uses the +configured Jerboa tree's vendored `vendor/jsqlite/src`, then transpiles this +repository into `build/lib`. + +For the FreeBSD jail deployment, build a single target binary with the +cross-build variables used by the Ansible playbook: + +```sh +make static-binary \ + STATIC_TARGET_MACHINE=ta6fb \ + STATIC_RUST_TARGET=x86_64-unknown-freebsd \ + STATIC_BINARY_OUTPUT=dist/jerboa-imagesite-freebsd-amd64 +``` ## Test ```sh make test +make web-security-smoke ``` +The hostile smoke starts the standalone server on loopback and proves that +credentials are POST-only, cross-origin and missing-CSRF requests fail, GET +mutations are inert, opaque sessions rotate at login, and a media symlink swap +cannot escape the configured root. + ## Run Locally ```sh export JERBOA_IMAGESITE_SESSION_SECRET="$(openssl rand -hex 32)" -export JERBOA_IMAGESITE_ACCESS_ANSWER="beth" -export JERBOA_IMAGESITE_MEDIA_ROOT="/srv/wendyfourniersandborn/photos/published" +export JERBOA_IMAGESITE_ACCESS_ANSWER="<family-answer>" +export JERBOA_IMAGESITE_MEDIA_ROOT="/srv/wendyfourniersanborn/photos/published" export JERBOA_IMAGESITE_DB="/var/db/jerboa-imagesite/imagesite.db" make run ``` Then visit `http://127.0.0.1:4580/`. +For direct TLS, set cert and key paths: + +```sh +export JERBOA_IMAGESITE_PORT=8443 +export JERBOA_IMAGESITE_TLS_CERT=/path/fullchain.cer +export JERBOA_IMAGESITE_TLS_KEY=/path/privkey.key +make run +``` + For a first local smoke test without production media: ```sh @@ -53,7 +79,7 @@ mkdir -p var/media/family var/db cp some-photo.jpg var/media/family/ JERBOA_IMAGESITE_SESSION_SECRET=dev-secret \ JERBOA_IMAGESITE_SESSION_SECURE=0 \ -JERBOA_IMAGESITE_ACCESS_ANSWER=beth \ +JERBOA_IMAGESITE_ACCESS_ANSWER=dev-answer \ JERBOA_IMAGESITE_MEDIA_ROOT="$PWD/var/media" \ JERBOA_IMAGESITE_DB="$PWD/var/db/imagesite.db" \ make run @@ -62,22 +88,29 @@ make run In another shell: ```sh -curl -H "X-Imagesite-Admin-Token: dev-admin" \ +curl -X POST -H "X-Imagesite-Admin-Token: dev-admin" \ http://127.0.0.1:4580/admin/sync ``` Set `JERBOA_IMAGESITE_ADMIN_TOKEN=dev-admin` before starting the server if you want the admin sync endpoint enabled. +Browser gate/admin credentials are accepted only in form-encoded POST bodies. +They are never accepted from query parameters. Browser mutations require a +random, session-bound CSRF value and same-origin validation. The cookie holds +only an opaque random session identifier; it is `HttpOnly`, `SameSite=Strict`, +and `Secure` by default. The administrator grant expires server-side after one +hour and login failures are rate-limited and audited without logging answers. + ## Filesystem Layout -The current Ansible/Piwigo deployment stages media like this: +The Ansible deployment stages media like this: ```text -/srv/wendyfourniersandborn/photos/inbox -/srv/wendyfourniersandborn/photos/metadata -/srv/wendyfourniersandborn/photos/classified -/srv/wendyfourniersandborn/photos/published +/srv/wendyfourniersanborn/photos/inbox +/srv/wendyfourniersanborn/photos/metadata +/srv/wendyfourniersanborn/photos/classified +/srv/wendyfourniersanborn/photos/published ``` `jerboa-imagesite` indexes only the configured published media root. Directory @@ -93,6 +126,13 @@ Rows are marked missing during sync when a previously indexed file is absent. They are not deleted automatically, so a bad mount or partial rsync does not destroy catalog work. +The media root is opened as a no-follow directory descriptor. Walks use +descriptor-relative `openat`/`fstatat`, reject links and inode swaps, stay on the +root filesystem, remember visited device/inode pairs, and enforce depth (32), +entry (100,000), per-file (8 GiB), and aggregate (256 GiB) limits. Direct media +serving is development-only and disabled by default; production deployments +should use an internal nginx `X-Accel-Redirect` location with symlinks disabled. + ## Metadata Sidecars Sidecar import is deliberately simple and append-only friendly. For a media file new file mode 100644 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Security + +Report vulnerabilities privately to the repository maintainers. Do not include +family answers, administrator tokens, session cookies, private media, access +logs, or target-host configuration in a public issue. + +The family answer and browser administrator answer are accepted only in +form-encoded POST bodies. Administrator automation uses the +`X-Imagesite-Admin-Token` request header. No credential is accepted from a URL. +Rotate any credential that was historically placed in a query string. + +Every browser mutation is POST-only and requires a random synchronizer token +bound to the opaque server-side session. Origin and Referer, when present, must +match `JERBOA_IMAGESITE_ORIGIN`. Cookies use `Secure`, `HttpOnly`, and +`SameSite=Strict` in production. The administrator grant expires after one hour; +five failed logins in five minutes rate-limit the transport peer. + +The media and face roots must be real directories, not symlinks. Enumeration +and serving are descriptor-relative and no-follow. Links are skipped, directory +and file inode identity is checked across opens, filesystem crossings and inode +cycles are rejected, and traversal has fixed depth, entry, per-file, and total +byte budgets. Production direct serving is disabled; configure the documented +internal nginx location with `disable_symlinks on`. + +Access logs are bounded JSON objects. Query values are replaced by a marker and +C0/C1 controls are replaced before encoding. Forwarding headers are never a +source of client identity unless the accepted transport peer can be matched to +an exact configured proxy. + +Run `make verify` before review. A local pass is not production certification. +Release remains blocked while this repository or its effective Jerboa/Sinatra +dependencies are dirty, while target TLS/proxy/filesystem proof is absent, or +while the built dependency graph does not match reviewed source hashes. --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -1,6 +1,6 @@ server { listen 80; - server_name wendyfourniersandborn.com www.wendyfourniersandborn.com; + server_name wendyfourniersanborn.com www.wendyfourniersanborn.com; location /.well-known/acme-challenge/ { root /usr/local/www/nginx; @@ -13,18 +13,26 @@ server { server { listen 443 ssl; - server_name wendyfourniersandborn.com www.wendyfourniersandborn.com; + server_name wendyfourniersanborn.com www.wendyfourniersanborn.com; - ssl_certificate /etc/ssl/wendyfourniersandborn.com/wendyfourniersandborn.com.fullchain.pem; - ssl_certificate_key /etc/ssl/wendyfourniersandborn.com/wendyfourniersandborn.com.key; + ssl_certificate /etc/ssl/wendyfourniersanborn.com/wendyfourniersanborn.com.fullchain.pem; + ssl_certificate_key /etc/ssl/wendyfourniersanborn.com/wendyfourniersanborn.com.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; - client_max_body_size 512M; + client_max_body_size 4M; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy no-referrer always; + # Must match JERBOA_IMAGESITE_MEDIA_ACCEL_PREFIX. This location is never + # reachable directly and refuses every symlink in the archive path. + location /_protected_media/ { + internal; + alias /srv/<archive>/photos/published/; + disable_symlinks on; + } + location / { proxy_pass http://127.0.1.20:4580; proxy_set_header Host $host; @@ -35,4 +43,3 @@ server { proxy_http_version 1.1; } } - --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,8 +1,8 @@ # Deployment -The existing Piwigo deployment runs in a FreeBSD jail behind host nginx. The -replacement keeps the same public front door, TLS certificate, and published -media tree, but removes PHP, MariaDB, and the Piwigo plugin runtime. +The existing Piwigo deployment ran in a FreeBSD jail behind host nginx. The +replacement keeps the dedicated jail, TLS certificate, and published media +tree, but removes nginx, PHP, MariaDB, and the Piwigo plugin runtime. ## Runtime Inputs @@ -12,11 +12,17 @@ JERBOA_IMAGESITE_MEDIA_ROOT=/srv/<archive>/photos/published JERBOA_IMAGESITE_METADATA_ROOT=/srv/<archive>/photos/metadata JERBOA_IMAGESITE_DERIVATIVE_ROOT=/srv/<archive>/photos/derivatives JERBOA_IMAGESITE_BIND=127.0.1.20 -JERBOA_IMAGESITE_PORT=4580 +JERBOA_IMAGESITE_PORT=443 +JERBOA_IMAGESITE_ORIGIN=https://<domain> +JERBOA_IMAGESITE_TLS_CERT=/etc/ssl/<domain>/fullchain.cer +JERBOA_IMAGESITE_TLS_KEY=/etc/ssl/<domain>/<domain>.key JERBOA_IMAGESITE_SESSION_SECRET=<random 32+ bytes> JERBOA_IMAGESITE_ACCESS_QUESTION="What was Wendy's middle name?" JERBOA_IMAGESITE_ACCESS_ANSWER=<answer> JERBOA_IMAGESITE_ADMIN_TOKEN=<random admin token> +JERBOA_IMAGESITE_ADMIN_ANSWER=<separate high-entropy browser password> +JERBOA_IMAGESITE_DIRECT_MEDIA=0 +JERBOA_IMAGESITE_MEDIA_ACCEL_PREFIX=/_protected_media/ ``` ## Sync @@ -25,29 +31,49 @@ Run sync after rsyncing approved media: ```sh curl -fsS \ + -k \ + -X POST \ -H "X-Imagesite-Admin-Token: $JERBOA_IMAGESITE_ADMIN_TOKEN" \ - http://127.0.1.20:4580/admin/sync + https://127.0.1.20:443/admin/sync ``` The syncer marks all existing media rows missing at the start of a transaction, then clears that marker as it sees files. If the sync fails, the transaction is rolled back. -## nginx - -Use nginx for TLS and large static files: - -- `/` proxies to the Jerboa app; -- `/app.css` can proxy or be served from the app public directory; -- `/media/` should alias the published media root after the family gate has - been passed by the app, or proxy to the app for simpler first deployment; -- `/derivatives/` can alias generated previews/thumbs when available. - -The simple first deployment is to proxy everything to Jerboa. The scalable -deployment uses nginx `auth_request` against a future `/auth/media` endpoint -before serving media aliases directly. - -See `deploy/nginx.conf.example` for the initial proxy configuration. +## TLS And Media + +The FreeBSD deployment follows the `jerboa.sh` shape: Jerboa terminates TLS +directly with cert paths supplied through the environment. The host uses a +small `pf` redirect from public TCP/443 into the jail, so host DNS services can +keep the public address while the web app remains jailed. + +`/media/` remains behind the family gate. In production the application first +opens the requested path relative to a retained, no-follow media-root +descriptor and then emits an internal `X-Accel-Redirect`. Configure nginx's +internal location with `disable_symlinks on`; do not expose that location or +the archive alias directly. Direct application media serving is an explicit +development mode, caps non-range responses at 16 MiB, and is disabled by +default. + +The gate and administrator login accept secrets only in POST bodies. Never put +an answer or token in a URL, bookmark, monitoring probe, or redirect. Session +cookies contain random opaque identifiers and use `Secure`, `HttpOnly`, and +`SameSite=Strict`; set `JERBOA_IMAGESITE_ORIGIN` to the single canonical HTTPS +origin so Origin/Referer validation fails closed. Rotate any answer or token +that was previously used in a URL. + +Forwarding headers are not trusted by default. If a future HTTP transport +provides the accepted peer address to the logger, list exact proxy addresses in +`JERBOA_IMAGESITE_TRUSTED_PROXIES`; never use a wildcard or client-controlled +subnet. The current logger emits one bounded JSON object per request, omits +query values, escapes controls, and records the transport peer rather than +blindly trusting `X-Forwarded-For`. + +Synchronization rejects symlinks and cross-device directories, verifies inode +identity between enumeration and open, and caps traversal to 32 levels, +100,000 entries, 8 GiB per media file, and 256 GiB total. Treat a limit failure +as an ingestion error to investigate, not as a reason to disable the bounds. ## Replacing Piwigo @@ -61,6 +87,6 @@ The pieces that stay: - host-side archive workspace; - rsync staging flow; -- TLS certificate and host nginx front door; +- TLS certificate; - family access question; - published-only media policy. new file mode 100644 --- /dev/null +++ b/docs/release-evidence.md @@ -0,0 +1,21 @@ +# Release evidence + +`make release-evidence` records local test, hostile HTTP smoke, source scan, +standalone build, effective dependency/SBOM, and two-build reproducibility +results under `dist/release-evidence/`. + +A local pass must contain `local_release_status=pass`. It deliberately does not +claim production readiness. Promotion additionally requires: + +- a clean, committed Imagesite snapshot (the audit started from a heavily dirty + mixed tree); +- clean, reviewed Jerboa and Sinatra source inputs matching the recorded hashes; +- target-host proof of TLS, exact bind address, internal nginx media location, + `disable_symlinks on`, jail/sandbox policy, file ownership, and backup restore; +- an external dependency/advisory scan of the effective linked artifact; +- a reviewed credential rotation confirming no historical URL answer/token + remains valid; and +- sustained load and filesystem-race testing on the deployment OS. + +The local SBOM captures both Git revisions and current source manifests so a +dirty adjacent dependency cannot be mistaken for its HEAD commit. new file mode 100644 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,31 @@ +# Threat model + +The application protects a private media catalog from unauthenticated web +clients and treats media/staging directory entries as hostile. Browser history, +reverse-proxy logs, referrers, cross-site navigation, crawlers, malformed URL +normalization, symlinks, mount aliases, concurrent path replacement, oversized +trees, and control characters in request metadata are in scope. + +The trusted computing base is the reviewed Imagesite source, the exact Jerboa +and Sinatra sources used for the build, the native secure-filesystem shim, the +kernel, TLS endpoint, and a correctly configured reverse proxy/jail. A host +administrator, kernel compromise, malicious replacement of the built binary, +or direct access to the underlying media volume is outside the web application's +boundary. + +Security invariants: + +- GET and HEAD never modify authentication or gallery state. +- Answers and bearer tokens never appear in request targets, redirects, HTML, + cookies, or logs. +- Browser state changes require both a live opaque session and its CSRF value. +- Redirect destinations have no scheme, authority, backslash, control, encoded + separator, double-decode marker, or unknown application prefix. +- A directory entry can be indexed or served only through an opened descriptor + descended from the retained root without following a link. +- One request contributes at most one bounded JSON log record and no query + value. + +The hostile module and HTTP smoke suites cover these invariants. Production +promotion additionally requires target-host TLS/proxy, jail policy, filesystem, +load, backup/restore, and operator credential-rotation evidence. --- a/public/app.css +++ b/public/app.css @@ -1,23 +1,32 @@ :root { color-scheme: light; - --ink: #222; - --muted: #666; - --line: #d9ddd3; - --paper: #fbfbf8; + --ink: #1d211d; + --muted: #68706a; + --soft: #f4f1eb; + --paper: #fbfaf7; --panel: #ffffff; - --accent: #2d6a4f; - --accent-2: #7a4e2d; + --line: #d8d7cf; + --accent: #23614f; + --accent-strong: #16483b; + --warm: #8a5a34; + --rose: #9f4c55; + --shadow: 0 12px 28px rgba(35, 39, 33, 0.12); } * { box-sizing: border-box; } +html { + background: var(--paper); +} + body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); - background: var(--paper); + background: + linear-gradient(180deg, #f7f2ea 0, var(--paper) 240px); } a { @@ -27,37 +36,55 @@ a { .topbar { position: sticky; top: 0; - z-index: 2; + z-index: 5; display: grid; - grid-template-columns: minmax(190px, 1fr) minmax(240px, 520px) auto; - gap: 16px; + grid-template-columns: minmax(220px, 1fr) minmax(260px, 560px) auto; + gap: 18px; align-items: center; - padding: 12px 20px; - border-bottom: 1px solid var(--line); - background: rgba(251, 251, 248, 0.96); + padding: 12px 22px; + border-bottom: 1px solid rgba(216, 215, 207, 0.92); + background: rgba(251, 250, 247, 0.96); + backdrop-filter: blur(16px); } .brand { - font-weight: 700; + font-weight: 800; text-decoration: none; } nav { display: flex; - gap: 12px; + gap: 14px; + justify-content: flex-end; font-size: 14px; } nav a, .back { color: var(--accent); + font-weight: 650; text-decoration: none; } +.nav-action { + display: inline; + margin: 0; +} + +.nav-action button { + min-height: 0; + padding: 0; + border: 0; + color: var(--accent); + background: transparent; + font-weight: 650; + cursor: pointer; +} + main { - width: min(1180px, calc(100vw - 32px)); + width: min(1380px, calc(100vw - 32px)); margin: 0 auto; - padding: 28px 0 56px; + padding: 30px 0 64px; } section { @@ -65,19 +92,22 @@ section { } .hero { - padding: 24px 0 10px; + display: grid; + gap: 8px; + padding: 18px 0 4px; } h1 { margin: 0 0 8px; - font-size: clamp(30px, 5vw, 58px); - line-height: 1; + font-size: 44px; + line-height: 1.04; letter-spacing: 0; } h2 { margin: 0 0 14px; - font-size: 22px; + font-size: 21px; + letter-spacing: 0; } p { @@ -97,6 +127,8 @@ p { } input, +textarea, +select, button { min-height: 40px; border: 1px solid var(--line); @@ -104,28 +136,350 @@ button { font: inherit; } -input { +input, +textarea, +select { width: 100%; padding: 0 12px; background: white; } +select { + min-width: 0; +} + +textarea { + min-height: 112px; + padding-top: 10px; + resize: vertical; +} + button { - padding: 0 14px; + padding: 0 15px; border-color: var(--accent); color: white; background: var(--accent); cursor: pointer; } +button:hover { + background: var(--accent-strong); +} + +button.danger { + border-color: #9d1c1c; + background: #9d1c1c; +} + +button.danger:hover { + background: #7b1515; +} + +.admin-actions, +.admin-panel, +.contribute { + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.admin-mode { + position: sticky; + top: 65px; + z-index: 4; + display: flex; + gap: 14px; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border: 2px solid #9d1c1c; + border-radius: 8px; + color: #7b1515; + background: #fff3f3; + box-shadow: 0 10px 24px rgba(157, 28, 28, 0.16); +} + +.admin-mode strong { + letter-spacing: 0; +} + +.admin-mode a, +.admin-mode .nav-action button { + color: #7b1515; + font-weight: 750; +} + +.admin-actions form { + display: flex; + gap: 10px; +} + +.media-admin-primary { + border-color: rgba(157, 28, 28, 0.38); + background: #fff7f7; +} + +.media-admin-primary form { + display: block; +} + +button.hide-current { + display: inline-flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + gap: 10px; + font-size: 17px; + font-weight: 850; +} + +button.hide-current span { + font-size: 32px; + line-height: 1; +} + +.contribute form { + display: grid; + gap: 12px; +} + +.contribute label { + display: grid; + gap: 7px; + color: var(--muted); + font-weight: 650; +} + +.hidden-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.hidden-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; +} + +.hidden-list code { + overflow-wrap: anywhere; +} + +.duplicate-groups { + display: grid; + gap: 14px; +} + +.duplicate-group { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px 18px; + align-items: start; + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.duplicate-group h2 { + margin-bottom: 4px; +} + +.duplicate-list { + grid-column: 1 / -1; + display: grid; + gap: 6px; + margin: 0; + padding-left: 20px; +} + +.duplicate-list a { + overflow-wrap: anywhere; +} + +.people-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; +} + +.person-card { + display: grid; + gap: 14px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: white; + box-shadow: var(--shadow); +} + +.person-card-saved { + opacity: 0; + transform: translateY(-6px); + transition: opacity 160ms ease, transform 160ms ease; +} + +.person-card-head { + display: flex; + gap: 12px; + align-items: start; + justify-content: space-between; +} + +.person-card-head h2 { + margin: 0 0 4px; + font-size: 20px; +} + +.person-card-head code { + color: var(--muted); + font-size: 12px; +} + +.person-job { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin: 0; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #f8faf8; + font-size: 13px; +} + +.person-job strong { + color: var(--accent); +} + +.person-job span { + color: var(--muted); +} + +.person-job-running, +.person-job-queued { + border-color: rgba(35, 97, 79, 0.32); + background: #eef7f3; +} + +.person-job-done { + border-color: rgba(35, 97, 79, 0.22); +} + +.person-job-error { + border-color: rgba(157, 28, 28, 0.36); + background: #fff3f3; +} + +.person-job-error strong { + color: #7b1515; +}