epic-fab authenticates against Epic Games and persists OAuth tokens to:
$XDG_CONFIG_HOME/epic-fab/auth.json (default: ~/.config/epic-fab/auth.json)
The directory is created 0700 and the file is written 0600 via a temp-file +
rename so it is never briefly world-readable. Those tokens grant access to the
account's Fab library and identity. Treat the file like an SSH private key.
epic-fab never logs, prints, or transmits tokens anywhere other than Epic's own
account-public-service-prod03.ol.epicgames.com token endpoint.
Open a private security advisory through GitHub on this repository rather than a
public issue. Please include a reproduction and the version (epic-fab --version).
epic-fab is a CLI. It makes outbound HTTPS calls to Epic and nothing else. It
does not bind a port. This is a deliberate security boundary, enforced by
scripts/no-listener-guard.ts (bun run guard), which fails the build if a
listener is introduced.
The reason is specific. A local web UI was proposed that called:
Bun.serve({ port: opts.port, fetch: handler }) // no hostname → binds 0.0.0.0with four state-changing routes and no request-origin validation:
| Route | Effect |
|---|---|
POST /api/auth |
Writes Epic OAuth tokens to disk |
POST /api/logout |
Deletes tokens — logs the user out |
POST /api/download |
Starts a download; caller-controlled into path |
POST /api/download/<jobId>/cancel |
Aborts an in-flight job |
plus GET /api/status, /api/library, /api/jobs, /api/events, which return
the account's identity and full owned-asset library.
Two separate attackers reach that server:
-
Anyone on the same network. Bun's
serve()defaults to0.0.0.0when nohostnameis given. On shared Wi-Fi, a hotel network, or a home LAN with an untrusted device,curl http://<your-ip>:<port>/api/libraryreturns the user's Epic identity, and a POST logs them out or triggers downloads. -
Any website the user visits.
localhostis not a security boundary in a browser. With no CSRF token and noOrigincheck, a page athttps://example.comcan issue a cross-originfetch()or auto-submit a form tohttp://localhost:<port>/api/logout. Without aHostcheck, DNS rebinding also reaches the same routes and can read responses.
If a local UI is ever added, it must satisfy all four of these — not three:
- Bind loopback explicitly —
Bun.serve({ hostname: "127.0.0.1", port }). Never rely on the default. - Reject cross-origin requests — require
Originto be exactlyhttp://127.0.0.1:<port>orhttp://localhost:<port>on every state-changing method. AbsentOriginon a POST is a rejection, not a pass. - Validate
Host— accept only127.0.0.1:<port>/localhost:<port>. This is what stops DNS rebinding; theOrigincheck alone does not. - Require an unguessable per-session token — printed to the terminal at startup and sent as a header (not a cookie, so it cannot ride along automatically) on every state-changing request.
Additionally: the download target path must be validated against traversal
before use, since an unvalidated into on a reachable route is an arbitrary
filesystem write, which is a worse outcome than the token exposure.
Only the latest release on master receives security fixes.