No description
  • TypeScript 98.9%
  • JavaScript 0.9%
  • Dockerfile 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Xavier REVEILLON 6f663fa766
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m45s
release / build + scan + publish (push) Successful in 49s
CI / pnpm gate (typecheck, lint, build, test) (pull_request) Successful in 1m43s
v2.3 close-out: README knob row + plan status finalization
README operator table gains the SQL_QUERY_LIMIT_NUMBER_FILES row (default 500, /config exposure, entry-gate + picker-commit + folder-add-refusal consumers). v2.3.md: all tasks completed, gate outcomes recorded (3012 unit tests, integration 7/7, E2E 73/0/5-skip), zero spec/ADR conflicts, docs close-out noted. The plan file stays until the post-tag deletion commit per the lifecycle rule.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
2026-08-31 18:04:42 +00:00
.forgejo/workflows ci: gate invokes the serialized root test script; AGENTS.md test row reworded (v2.2.1 T-B2, ADR #53) 2026-08-31 05:15:38 +00:00
design-system chore(design-system): vendor xreveillon snapshot 2026-08-04 20:38:55 +00:00
docs v2.3 close-out: README knob row + plan status finalization 2026-08-31 18:04:42 +00:00
packages v2.3 W3: recursive folder adds in the SQL source picker (ADR #54 par.1-3) 2026-08-31 17:19:12 +00:00
scripts ci(i18n): pin gate to official node:24.19.0 + ICU locale canary (ADR #45) 2026-08-19 08:35:02 +00:00
tests/e2e v2.3 W3-T5: folder-add + source-count-cap E2E on the MinIO stacks 2026-08-31 17:59:02 +00:00
vendor web: add v1.1 deps (duckdb-wasm 1.32.0, codemirror, vendored SheetJS) (v1.1-T2) 2026-08-12 01:27:19 +00:00
.dockerignore fix(docker): re-admit vendored xlsx tarball stripped from build context 2026-08-12 14:51:39 +00:00
.editorconfig chore(repo): bootstrap pnpm workspace and shared TS tooling (F1) 2026-08-04 22:08:13 +00:00
.gitignore docs: document mandatory AUTHORIZED_ENDPOINTS knob + retire endpoint-allowlist plan 2026-08-09 19:42:56 +00:00
.mise.toml chore: add mise.toml for tool configuration 2026-08-09 08:16:55 +00:00
.nvmrc chore(repo): bootstrap pnpm workspace and shared TS tooling (F1) 2026-08-04 22:08:13 +00:00
.prettierignore chore(repo): bootstrap pnpm workspace and shared TS tooling (F1) 2026-08-04 22:08:13 +00:00
.prettierrc.json chore(repo): bootstrap pnpm workspace and shared TS tooling (F1) 2026-08-04 22:08:13 +00:00
.trivyignore ci: ignore CVE-2026-59873 — base-image npm-bundled tar, not an app dep 2026-08-09 10:19:55 +00:00
AGENTS.md ci: gate invokes the serialized root test script; AGENTS.md test row reworded (v2.2.1 T-B2, ADR #53) 2026-08-31 05:15:38 +00:00
compose.e2e.yaml e2e: enforce always-rebuild on the E2E compose stacks 2026-08-15 17:46:42 +00:00
compose.h1.yaml chore: rename compose files to compose.{h1,e2e,e2e.dropdown}.yaml 2026-08-13 19:45:06 +00:00
CONTRIBUTING.md docs: E2E runbook — drop-down specs need the second compose stack 2026-08-18 04:27:18 +00:00
Dockerfile fix(docker): re-admit vendored xlsx tarball stripped from build context 2026-08-12 14:51:39 +00:00
eslint.config.mjs Wave A security hardening: rate-limit, HTTPS gate, CSP, request caps (ADRs #24-#27) 2026-08-10 03:00:05 +00:00
package.json test: serialize the unit battery via the root script — workspace-concurrency=1 (v2.2.1 T-B1, ADR #53) 2026-08-31 05:15:38 +00:00
pnpm-lock.yaml feat(web): held-alias autocompletion in the SQL editor — quoted-literal apply, live store read (ADR #49 §3) 2026-08-23 06:07:39 +00:00
pnpm-workspace.yaml fix(supply-chain): make the 7-day age gate real — numeric minimumReleaseAge (v1.1.2 item 2, Option C) 2026-08-15 01:50:35 +00:00
README.md v2.3 close-out: README knob row + plan status finalization 2026-08-31 18:04:42 +00:00
tsconfig.base.json chore(repo): bootstrap pnpm workspace and shared TS tooling (F1) 2026-08-04 22:08:13 +00:00

s3-vedrfolnir

A web-based S3 browser for any S3-compatible object store — AWS S3, MinIO, and anything else that speaks the S3 API. You bring your own credentials; the app holds none.

It ships as a single Docker image. Inside, a Fastify proxy serves the React single-page app and relays your S3 calls on the same origin. Because the browser and the proxy share an origin, there is no CORS to configure — and because the proxy is a streaming byte-relay, it works against any S3 backend with zero per-endpoint setup.

Highlights

  • BYO credentials. Enter your endpoint, access key, and secret key in the browser. There is no application-level user account and no server-side credential store.
  • Connection profiles. Save an endpoint + access-key pair on the login form for quick re-login. The secret key is never stored with a profile — selecting one pre-fills those two fields and the secret is still entered each time (full-credential persistence remains the separate "remember on this device" opt-in). Profiles are device-local (localStorage): no sync, nothing reaches the server.
  • One image, one origin. docker build, docker run, open the port. The proxy serves the UI and the relay API from the same origin — no separate frontend server.
  • Stateless proxy. No session data is held between requests; credentials travel per-request via headers.
  • Credentials are never logged. pino's redact config strips them at logger construction, so they cannot leak under any error path.
  • Streaming transfers. Uploads (including multipart for objects > 5 GB) and downloads stream through the proxy without buffering whole files. Bulk multi-object download assembles a zip on the fly — no temp file.
  • English + French UI. Switch language in the app; the choice persists per browser. Operators set the default locale and can restrict the offered set (v2 ships en-US + fr-FR).
  • Optional built-in TLS. Mount a certificate chain + private key and the proxy serves HTTPS itself — encrypted traffic with no reverse proxy (details).

Quick start

Two ways to run it — build from source, or pull the published Docker image. Both serve the React UI and the S3 relay on the same origin.

From source

Requires Node.js 24.19.0 (the engines field pins >=24.19.0 <25) and pnpm 11.20.0. Corepack ships with Node, so there is no separate pnpm install — corepack enable && corepack prepare pnpm@11.20.0 --activate activates the version pinned in packageManager.

git clone https://forgejo.xreveillon.eu/xavier/s3-vedrfolnir.git
cd s3-vedrfolnir
corepack enable && corepack prepare pnpm@11.20.0 --activate   # enables the pinned pnpm (Node 24 ships corepack)
pnpm install --frozen-lockfile                                 # workspace install against the committed lockfile
pnpm build                                                     # builds shared -> proxy -> web (topological)
AUTHORIZED_ENDPOINTS=https://s3.example.com ALLOW_INSECURE_HTTP=true node packages/proxy/dist/server.js

packages/proxy/dist/server.js is the same entry point the Docker image runs (its CMD), and it takes the same environment variables as the Docker image (AUTHORIZED_ENDPOINTS, ALLOW_INSECURE_HTTP, PORT defaulting to 8080, etc.). The proxy serves the built web bundle on the same origin, so once it has started, just open the port in a browser — no separate frontend server.

With Docker

Pull the prebuilt image — no docker build needed. The release pipeline (.forgejo/workflows/release.yml) publishes latest from main, v* version tags, and dev (rolling) plus dev-<sha> from dev. Use :latest to track stable, :dev to track the latest dev build, or pin a v* tag for reproducibility.

docker run \
  -e AUTHORIZED_ENDPOINTS=https://s3.example.com \
  -e ALLOW_INSECURE_HTTP=true \
  -p 8080:8080 forgejo.xreveillon.eu/xavier/s3-vedrfolnir:latest

The image is based on node:24-slim, runs the proxy as the non-root node user, and carries only production dependencies at runtime.

Then open http://localhost:8080 and enter your access key + secret key.

Two knobs are needed for this quick local trial (both run paths):

  • AUTHORIZED_ENDPOINTS (mandatory, always) — the S3 backends users may connect to. The proxy refuses to boot without it (fail-closed).
  • ALLOW_INSECURE_HTTP=true (for a plaintext local trial only) — the proxy requires HTTPS by default and would otherwise refuse the plain-HTTP connection with https_required (HTTP 426). For any deployment others can reach, do NOT set this — put a TLS-terminating reverse proxy (nginx, Caddy, Traefik) in front with TRUSTED_PROXIES set, or arm the proxy's own in-process TLS. See Security model and Protecting a deployment.

Health check

GET /health returns {"status":"ok"} and is exempt from any protection you enable. It is what the Docker HEALTHCHECK polls:

curl http://localhost:8080/health

Security model

Read this before deploying it where others can reach it.

  • No persistent credential store. Credentials live in the browser: sessionStorage by default, localStorage only if you explicitly opt in to "remember on this device."
  • Stateless proxy. The proxy keeps no session data; each request carries its own credentials via headers.
  • No user accounts. The app performs no user authentication itself. Anyone who can reach the deployed URL can use it against any S3 backend they have credentials for.
  • Endpoint allowlist (mandatory). The proxy enforces an operator-configured positive allowlist (AUTHORIZED_ENDPOINTS) of the S3 backends users may reach. An endpoint not on the list is rejected with endpoint_forbidden (403) before any outbound connection is attempted — no DNS lookup, no TCP connect to the rejected host. This runs server-side on every request and /session/probe, regardless of the login UI mode. Network egress policy on the host (firewall rules / Kubernetes NetworkPolicy / a forward egress proxy) remains the recommended defense-in-depth layer — it complements, not replaces, this application-layer guard.
  • HTTPS by default. The proxy refuses plain-HTTP requests (https_required, 426) unless you explicitly set ALLOW_INSECURE_HTTP=true. For real deployments, terminate TLS at a reverse proxy and set TRUSTED_PROXIES so the proxy trusts the X-Forwarded-Proto header — or arm the proxy's own in-process TLS.
  • Per-IP rate limiting. Every route is rate-limited per IP, with a tighter failure-only budget on /session/probe (brute-force defense). Exceeding the budget returns rate_limited (429). Behind a reverse proxy, set TRUSTED_PROXIES so the limiter keys on the real client IP.
  • Strict security headers + CSP. A strict Content-Security-Policy (default-src 'self'), X-Content-Type-Options, frame-ancestors 'none', and HSTS are set on every response.

Protecting a deployment

Off by default. To add HTTP basic auth, set PROXY_BASIC_AUTH (alongside the mandatory AUTHORIZED_ENDPOINTS):

docker run \
  -e AUTHORIZED_ENDPOINTS=https://s3.example.com \
  -e PROXY_BASIC_AUTH=user:pass \
  -p 8080:8080 s3-vedrfolnir

This is enforced natively inside the image as a Fastify onRequest hook — no reverse proxy is required just to get auth. /health stays exempt so the Docker HEALTHCHECK keeps working while armed.

For Docker secrets, Kubernetes secret volume mounts, or any setup where the credential must not appear in docker inspect or docker exec env, use the file-based variant PROXY_BASIC_AUTH_FILE instead:

docker run \
  -e AUTHORIZED_ENDPOINTS=https://s3.example.com \
  -e PROXY_BASIC_AUTH_FILE=/run/secrets/proxy_basic_auth \
  -v ./proxy_basic_auth:/run/secrets/proxy_basic_auth:ro \
  -p 8080:8080 s3-vedrfolnir

The referenced file should contain the user:password string (a trailing newline is fine — it is trimmed). When both PROXY_BASIC_AUTH_FILE and PROXY_BASIC_AUTH are set, _FILE wins outright and the plain var is ignored. An unreadable path refuses to boot (fail-fast).

TLS is required by default. The proxy refuses plain HTTP (ALLOW_INSECURE_HTTP=off) regardless of whether basic auth is armed — basic auth over plaintext would additionally expose the password, but HTTPS is the baseline, not an opt-in. Terminate TLS with a reverse proxy (nginx, Caddy, or Traefik) and set TRUSTED_PROXIES to the proxy's address so the app trusts X-Forwarded-Proto/X-Forwarded-For — or, since v2, let the proxy terminate TLS itself with no front-end at all (in-process TLS). The example above assumes such a TLS front-end.

The endpoint allowlist is a separate, complementary layer: basic auth restricts who can reach the proxy, while AUTHORIZED_ENDPOINTS restricts which S3 backends those users may connect to. Both are off until you configure them — except the allowlist, which is mandatory.

In-process TLS

Since v2, the proxy can terminate TLS itself — encrypted browser↔proxy traffic straight from the single image, no reverse proxy required. Mount the certificate chain and private key, set the pair of knobs, and the proxy listens HTTPS on the same PORT:

docker run \
  -e AUTHORIZED_ENDPOINTS=https://s3.example.com \
  -e TLS_CERT_FILE=/run/secrets/tls_cert \
  -e TLS_KEY_FILE=/run/secrets/tls_key \
  -v ./cert.pem:/run/secrets/tls_cert:ro \
  -v ./key.pem:/run/secrets/tls_key:ro \
  -p 8080:8080 s3-vedrfolnir

Then open https://localhost:8080.

  • TLS_CERT_FILE — path to the public certificate chain PEM (leaf + intermediates). TLS_KEY_FILE — path to the private key PEM matching the leaf. Both are _FILE-only (PEM blobs don't fit env-var values); the key is secret-shaped — its path may appear in a boot error, its contents never.
  • Both or neither. Exactly one of the pair set refuses to boot — a half-configured pair is never silently downgraded to plaintext. Both unset → plain HTTP on PORT (the v1 behavior, unchanged).
  • An unreadable, malformed, or cert↔key-mismatched file refuses to boot (fail-fast, generic error — no file contents echoed).
  • The listener speaks HTTP/1.1 over TLS (no HTTP/2) with a pinned modern floor: TLS 1.2 minimum, AEAD-only cipher suites (TLS 1.3 included).
  • /health rides the same TLS listener — orchestrator liveness probes must use HTTPS. No separate plaintext health port.
  • Composition is unchanged: leave ALLOW_INSECURE_HTTP unset (the gate passes natively — the socket itself is TLS) and TRUSTED_PROXIES unset (nothing to trust with no proxy in front). The plain-HTTP test compose stacks are unaffected.

When to use it vs a reverse proxy. In-process TLS exists so a single-team deployment gets encrypted traffic from the one image without standing up nginx/Caddy/Traefik. Keep the reverse proxy when you already run one, or when you need what it still does better: HTTP/2, SNI multi-hostname certificates, ACME/Let's Encrypt automation, or load balancing across replicas.

Rotation. Certificates are read once at boot — there is no hot-reload. To rotate, restart the container (an orchestrator rolling restart does this with no downtime). Certificate provisioning is BYO, like everything else here.

Full contract: docs/specs/native-tls.md.

Languages / Langues

The UI ships in English (en-US) and French (fr-FR). Users pick a language in the app; the choice persists per browser (localStorage) and switching applies immediately — no reload, no re-login. New sessions open in the operator-configured default, never in a browser-language guess.

Two operator knobs control the posture (see the configuration table):

  • DEFAULT_LOCALE — the locale a brand-new session opens in before the user chooses (unset → en-US). Must be a member of the offered set, or the proxy refuses to boot.
  • AVAILABLE_LOCALES — comma-separated BCP-47 tags offered in the picker. Unset → all shipped locales. The effective set is the operator's list ∩ the shipped catalog (tags the catalog doesn't contain are ignored). When set, it must contain the resolved default (DEFAULT_LOCALE, else en-US) — e.g. AVAILABLE_LOCALES=fr-FR alone with no default is a boot-time configuration error (fail-closed, no implicit en-US added).

A French-only deployment:

docker run \
  -e AUTHORIZED_ENDPOINTS=https://s3.example.com \
  -e DEFAULT_LOCALE=fr-FR \
  -e AVAILABLE_LOCALES=fr-FR \
  -p 8080:8080 s3-vedrfolnir

Behavioral source of truth: docs/specs/i18n.md.

Configuration

All settings are read once at proxy startup from environment variables. The frontend learns the enforced values from a /config endpoint, so the warnings you see in the UI always match what the proxy actually enforces. The full reference is docs/specs/deployment-config.md; the operator-tunable knobs (PORT is a proxy runtime knob; the behavioral limits are documented in the spec):

Variable Default Description
AUTHORIZED_ENDPOINTS (none → refuse to boot) Mandatory. Comma-separated endpoint URLs users may connect to. The proxy refuses to boot without it (fail-closed). Enforced server-side on every request and /session/probe; an unlisted endpoint returns endpoint_forbidden (403) with no outbound connection (no DNS, no connect to the host). Exact-URL match; trivial formatting (trailing slash, default port, case) is normalized away.
PORT 8080 Port the proxy listens on.
PROXY_BASIC_AUTH off Optional HTTP basic auth as user:pass. Off when unset/empty.
PROXY_BASIC_AUTH_FILE off File-based variant of PROXY_BASIC_AUTH for Docker-secrets / mounted-credential injection: a path to a file whose contents are the user:pass string. When set and non-empty it wins outright — the plain PROXY_BASIC_AUTH var is ignored even if also set. Leading/trailing whitespace (including a trailing newline) is trimmed; an empty/whitespace-only file leaves auth off. An unreadable path (missing, permission denied) refuses to boot (fail-fast) — the path may appear in the error, the file contents never do. See docs/specs/deployment-config.md for the full _FILE resolution rules.
ALLOW_INSECURE_HTTP off When off (default) the proxy refuses plain HTTP with https_required (426) — secure-by-default. Set true only for a plaintext local trial; for real deployments put a TLS reverse proxy in front. Supersedes the old "no reverse proxy required" posture.
TRUSTED_PROXIES (unset) Comma-separated IP/CIDR list of trusted reverse-proxy addresses. When set, X-Forwarded-For/X-Forwarded-Proto are honored only for connections from a listed IP (drives the rate limiter's IP resolution and the ALLOW_INSECURE_HTTP gate). Unset → direct peer only.
TLS_CERT_FILE (unset → no native TLS) (v2) Path to the public certificate chain PEM (leaf + intermediates) for in-process TLS — see In-process TLS. Set together with TLS_KEY_FILE → the proxy serves HTTPS on PORT (encrypted traffic, no reverse proxy). _FILE-only; exactly one of the pair set refuses to boot. Not exposed via /config.
TLS_KEY_FILE (unset → no native TLS) (v2) Path to the private key PEM matching TLS_CERT_FILE. Secret-shaped: the path may appear in a boot error, the key contents never; unreadable/malformed/mismatched → boot error (fail-fast). Not exposed via /config.
SHOW_ENDPOINTS_DROPDOWN off Governs login UX and whether the allowlist reaches the browser. on → login shows a drop-down populated from AUTHORIZED_ENDPOINTS, and /config sends the list. off (default) → free-text login; the allowlist is never sent to the browser and a disallowed endpoint fails only on submit (stealth).
DEFAULT_ENDPOINT (unset) Optional. Pre-selects an endpoint in the login drop-down — only effective when SHOW_ENDPOINTS_DROPDOWN is on (ignored in free-text mode). Must be a member of AUTHORIZED_ENDPOINTS (validated at boot; a non-member refuses to boot).
ENDPOINT_<SCHEME>_<HOST>_<PORT>_AUTHORIZED_BUCKETS (unset → all) Optional, per endpoint. Comma-separated bucket names allowed for a given authorized endpoint. Unset → all buckets allowed (default). Set → only those are visible (filtered at /session/probe) and usable (enforced on every bucket op); never sent to the browser. Name is derived from the endpoint's canonical form: SCHEME lowercased, HOST lowercased with .→_, PORT always present (443 for https, 80 for http). E.g. for https://s3.example.com: ENDPOINT_https_s3_example_com_443_AUTHORIZED_BUCKETS=prod-bucket,staging-bucket.
SESSION_TIMEOUT 3 days Sliding inactivity window; older stored sessions must re-authenticate.
INSPECTION_SAMPLE_SIZE 10 MB Sampled-prefix inspection budget for CSV/TSV/JSON.
OBJECT_UPLOAD_LIMIT_SIZE 50 GB Hard cap on a single uploaded object (must stay ≥ 5 GB).
OBJECT_DOWNLOAD_LIMIT_SIZE 10 GB Hard cap on total download selection size (single + bulk zip).
BULK_ZIP_MAX_FILES 1000 Hard cap on the number of objects in one bulk-zip download. Exposed to the UI via /config (bulkZipMaxFiles).
MULTIPART_PART_MAX_SIZE 5 GiB Hard cap on a single streamed multipart part upload. Not exposed to the UI (client part sizing stays under the S3 ceiling).
SQL_QUERY_WARN_SIZE 250 MB SQL editor warn threshold (size at which the UI warns before loading).
SQL_QUERY_LIMIT_SIZE 1 GB SQL editor hard limit; also the combined cap for multi-file queries. Default lowered from 2 GB for v1.1.0 to stay under WASM heap ceilings (ADR #11); operator-overridable.
SQL_QUERY_LIMIT_NUMBER_FILES 500 Hard cap on the number of SQL Runner sources (entry snapshot + panel registry). Gates the SQL entry controls and the source picker's commit; refuses over-cap folder adds. Exposed to the UI via /config (sqlQueryLimitNumberFiles).
DEFAULT_LOCALE (unset → en-US) (v2) Locale a brand-new session opens in before the user chooses. Must be a member of the offered set (fail-closed boot on a non-member). See Languages / Langues.
AVAILABLE_LOCALES (unset → all shipped) (v2) Comma-separated BCP-47 tags offered in the language picker (v2 ships exactly en-US,fr-FR). Effective set = operator list ∩ shipped catalog (unknown tags ignored). When set, must contain the resolved default (DEFAULT_LOCALE, else en-US) — fr-FR alone with no default refuses to boot. See Languages / Langues.

Example with an override (any variable from the table above can be set the same way; see docs/specs/deployment-config.md for accepted value formats):

docker run \
  -e AUTHORIZED_ENDPOINTS=https://s3.example.com \
  -e PROXY_BASIC_AUTH=ops:s3cret \
  -e PORT=8080 \
  -p 8080:8080 s3-vedrfolnir

Roadmap

  • v1.0 — auth & session, bucket/prefix navigation (virtualized and paginated), upload (including multipart > 5 GB and drag-and-drop), download (single object + multi-object bulk zip), object operations (delete, folder, rename, copy), per-format metadata inspection, responsive UI shell, and cross-tab single-session.
  • v1.1 — in-browser read-only SQL query over CSV/Parquet/JSON via duckdb-wasm, plus a Data Inspector for CSV/TSV/JSON/Parquet/xlsx (columns, inferred types, row count, sample).
  • v2.0 (current) — internationalization: the full UI in English and French (en-US + fr-FR), operator-configurable default and offered locales, live in-app switching, locale-aware formatting (Languages / Langues); and native TLS termination — the proxy serves HTTPS itself from a mounted cert/key pair, no reverse proxy needed for encrypted traffic (In-process TLS).

Tech stack

Node.js 24 LTS (24.19.0) with a Fastify 5 proxy; React 19 + Vite 8 + TypeScript + Tailwind 4 frontend; pnpm workspace (pnpm 11.20.0).

For developers

This README is for users. If you want to work on the codebase:

  • CONTRIBUTING.md — the contributor guide for humans (workspace layout, build/test/lint commands, the three test layers, and the subtle invariants that are easy to get wrong).
  • AGENTS.md — the companion guide for AI agents working in this repo; deliberately consistent with CONTRIBUTING.md.

Deeper documentation

License

This project is intended to be open-sourced, but no license has been declared yet — there is no LICENSE file in the repository. Until one is added, treat the code as all rights reserved. Choosing the license is a prerequisite to any open-source release.