- TypeScript 98.9%
- JavaScript 0.9%
- Dockerfile 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| .forgejo/workflows | ||
| design-system | ||
| docs | ||
| packages | ||
| scripts | ||
| tests/e2e | ||
| vendor | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitignore | ||
| .mise.toml | ||
| .nvmrc | ||
| .prettierignore | ||
| .prettierrc.json | ||
| .trivyignore | ||
| AGENTS.md | ||
| compose.e2e.yaml | ||
| compose.h1.yaml | ||
| CONTRIBUTING.md | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.base.json | ||
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 withhttps_required(HTTP 426). For any deployment others can reach, do NOT set this — put a TLS-terminating reverse proxy (nginx, Caddy, Traefik) in front withTRUSTED_PROXIESset, 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:
sessionStorageby default,localStorageonly 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 withendpoint_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 / KubernetesNetworkPolicy/ 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 setALLOW_INSECURE_HTTP=true. For real deployments, terminate TLS at a reverse proxy and setTRUSTED_PROXIESso the proxy trusts theX-Forwarded-Protoheader — 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 returnsrate_limited(429). Behind a reverse proxy, setTRUSTED_PROXIESso 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).
/healthrides the same TLS listener — orchestrator liveness probes must use HTTPS. No separate plaintext health port.- Composition is unchanged: leave
ALLOW_INSECURE_HTTPunset (the gate passes natively — the socket itself is TLS) andTRUSTED_PROXIESunset (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, elseen-US) — e.g.AVAILABLE_LOCALES=fr-FRalone with no default is a boot-time configuration error (fail-closed, no impliciten-USadded).
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 withCONTRIBUTING.md.
Deeper documentation
docs/architecture/ARCHITECTURE.md— how it's built (current-state map + full tech stack).docs/architecture/DECISIONS.md— append-only architecture decision records (ADRs).docs/specs/— functional specs; the source of truth for product behavior, includingbackend-proxy.md,deployment-config.md,native-tls.md, andi18n.md.
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.