Security policy
Vulnerability reporting, supported versions, trust baseline, threat controls, and release verification for edgeweir and edgeweir-node.
简体中文:SECURITY.md
Scope
| Subject | Location |
|---|---|
| Console source | marvinli001/edgeweir |
| Node source | marvinli001/edgeweir-node |
| Console image | ghcr.io/marvinli001/edgeweir |
| Node release artifacts | edgeweir-node Releases |
Reporting a vulnerability
Do not report security vulnerabilities through public issues, discussions, or pull requests.
| Channel | Address |
|---|---|
| GitHub private security advisory (console) | https://github.com/marvinli001/edgeweir/security/advisories/new |
| GitHub private security advisory (node) | https://github.com/marvinli001/edgeweir-node/security/advisories/new |
Report contents:
| Item | Contents |
|---|---|
| Component and version | Affected component; image version, release version, or commit hash |
| Reproduction | Steps to reproduce or proof-of-concept code |
| Impact | What an attacker can achieve and the preconditions |
| Disclosure state | Whether the issue is public or known to be exploited |
Handling:
- Receipt of the report is acknowledged within 3 working days.
- After assessment, the reporter receives a preliminary conclusion and a fix plan, with progress updates during handling.
- The coordinated disclosure window is 90 days from the day the report is received. A security advisory is published after the fix; the reporter is credited in it with their consent.
- When a fix cannot ship within 90 days, an extension is agreed with the reporter; when the vulnerability is exploited in the wild, mitigations may be disclosed earlier.
Supported versions
| Component | Supported | Not supported |
|---|---|---|
| Console | The latest rolling version: the <YYYYMMDD>-<commit> image of the newest master commit (latest) | Earlier rolling versions |
| edgeweir-node | The latest master | Other commits |
Security fixes land on master only and are not backported. A console fix ships as a new rolling version with the next master commit that passes CI.
Trust baseline
These rules are hard constraints on all code in edgeweir and edgeweir-node.
| Rule | Contents |
|---|---|
| No phone-home | The console and the nodes never connect on their own to any server of the Edgeweir project (edgeweir.com, edgeweir.dev, and so on), version checks included |
| No license checks | The code has no license keys, online activation, or feature locks |
| Telemetry off by default | No telemetry is sent and there is no telemetry switch; telemetry may only be enabled explicitly by the operator, with the fields and destination listed before enabling; better-auth's own telemetry is hard-disabled |
| Envelope encryption of secrets | Private keys and third-party credentials are envelope-encrypted with the master key EDGEWEIR_MASTER_KEY before they reach the database; secrets that only need comparison are stored as hashes (see Sensitive data) |
| SSH credentials are never stored | The console has no option to store SSH credentials; nodes join only through the one-time install command generated by the console and enroll themselves |
| Management actions are audited | See Audit log |
| Verifiable releases | cosign keyless signatures, SBOMs, SLSA provenance (see Verifying releases) |
Separate commercial products (see LICENSING.en.md) may use licensing and cloud services under explicit terms after an administrator enables them, but must not add commercial feature locks to the open core and must not interrupt existing CDN traffic because an official license expired or a licensing service failed.
Sensitive data
Envelope encryption
| Item | Value |
|---|---|
| Master key | EDGEWEIR_MASTER_KEY: base64 of at least 32 random bytes (openssl rand -base64 32); never stored in the database |
| Master key format | Canonical base64 (standard or URL-safe alphabet); a space, a quote, or any other extra character stops the console instead of decoding to another key |
| Master key id | Envelopes record the key id kid (the first 16 hex characters of the SHA-256 of the raw master key), which picks the master key that decrypts them; at startup the kid of the internal CA key's envelope must be the current master key's or that of EDGEWEIR_MASTER_KEY_PREVIOUS, otherwise the console refuses to start with "master key does not match this database", before checking the session secret |
| Master key rotation | EDGEWEIR_MASTER_KEY_PREVIOUS (the key before the rotation) only decrypts. At startup, under an advisory lock, every stored envelope it sealed is opened and encrypted again with the current master key and a new data key, bound to the same row, and the log reports how many still use it; revision receipts held by nodes keep verifying while it is set. Steps: Rotating the master key |
| Key-encryption key | HKDF-SHA256, salt edgeweir/kek/v1, info envelope, 32 bytes |
| Data key | Random per record; data and data key both encrypted with AES-256-GCM |
| Additional authenticated data | edgeweir/envelope/v2, <table>.<column>, <record id>; a ciphertext moved to another row or column fails to decrypt |
| Format version | v2; v1 envelopes written by older versions (bound to the purpose only) are re-encrypted at console startup, and the read path rejects v1 |
Storage
| Data | Storage | Location |
|---|---|---|
| Internal CA private key | Envelope-encrypted | pki_authority.private_key_envelope |
| Certificate private keys | Envelope-encrypted | certificate.private_key_envelope |
| ACME accounts | Envelope-encrypted | acme_account.account_envelope; the EAB key of a request in certificate.account_envelope |
| ACME DNS-01 credentials | Envelope-encrypted | dns_credential.credential_envelope |
| DNS steering provider credentials | Envelope-encrypted | platform_dns_provider.credential_envelope |
| S3 origin keys | Envelope-encrypted | origin_credential.secret_envelope |
| Site PURGE keys | Envelope-encrypted | site_secret.secret_envelope |
| Alert channel configuration (webhook URL and bearer token, email recipients) | Envelope-encrypted | alert_channel.config_envelope |
| SMTP settings (including the password) | Envelope-encrypted | notification_smtp in system_setting |
| Setup token | Envelope-encrypted; SHA-256 kept for comparison | setup_token in system_setting |
| Challenge page signing keys | Envelope-encrypted | challenge_key.secret |
| Node enrollment tokens | SHA-256 | enrollment_token |
| Probe enrollment tokens | SHA-256 | probe_token |
| AccessKeys | Hash (better-auth) | apikey |
| User passwords | scrypt hash (better-auth) | account |
| TOTP secrets and backup codes | Encrypted with the session secret (better-auth) | two_factor |
| Session secret | Not stored, only an HMAC-SHA256 check value; after a master key rotation, the value derived from the old key is stored envelope-encrypted | auth_secret_check and auth_secret in system_setting |
| Master key | Not stored | Environment variable EDGEWEIR_MASTER_KEY, or the file named by EDGEWEIR_MASTER_KEY_FILE; during a rotation also EDGEWEIR_MASTER_KEY_PREVIOUS |
Session secret
better-auth's session secret signs session cookies and encrypts TOTP secrets and backup codes.
| Case | Behavior |
|---|---|
BETTER_AUTH_SECRET set (at least 32 characters) | That value is used |
BETTER_AUTH_SECRET unset | Derived from the master key with HKDF-SHA256: salt edgeweir/auth-secret/v1, info better-auth.secret, 32 bytes, base64url; independent of the envelope key-encryption key (salt edgeweir/kek/v1, info envelope) |
| Master key rotated, and the database ran with the secret derived from the old key | That secret stays: on the first start with EDGEWEIR_MASTER_KEY_PREVIOUS it is sealed with the current master key into auth_secret in system_setting and read from there from then on, also after EDGEWEIR_MASTER_KEY_PREVIOUS is removed; sessions and two-factor secrets are not affected |
The derived secret differs from the one the database was used with (for example BETTER_AUTH_SECRET removed from an existing deployment) | The console refuses to start |
| The explicit value changes | The console starts and logs a warning; existing sessions end and enrolled two-factor secrets can no longer be read |
Audit log
| Item | Behavior |
|---|---|
| Coverage | Writes through /rpc and /api/v1; setup; install command generation; node enrollment, certificate rotation, and deletion; probe token generation, enrollment, certificate renewal, and deletion; scheduling actions taking effect and recovering (system identity); successful and failed sign-ins, password changes, two-factor on and off, passkey add and delete, API key create and delete; account recovery on the server |
| Transactions | Edgeweir's own writes commit their audit entry in the same transaction; sign-ins and account changes completed by better-auth are committed by better-auth first and audited right after |
| Contents | No plain-text passwords, tokens, or keys |
| Source IP | The TCP peer; X-Forwarded-For and X-Real-IP are used only when the peer is in EDGEWEIR_TRUSTED_PROXIES |
| Changes | The application offers only queries; there is no interface to modify or delete audit entries |
Threats and controls
| Control | Threat |
|---|---|
| The console never stores SSH credentials; nodes join only through the one-time install command and enroll themselves | A compromised console uses SSH credentials to take over every node |
| Node private keys are generated on the node and never leave it; the console only issues certificates | A leaked console database is used to impersonate nodes |
| The install command pins the CA fingerprint, and the node checks it before sending the token; tokens are single-use, expire, and are stored as SHA-256 only | Man-in-the-middle on first contact; leaked or replayed tokens |
mTLS on every RPC except Enroll, with the client certificate serial equal to the stored current value (after a renewal also the replaced one, until the node first uses the new one); 30-day certificates with automatic rotation; disabling or deleting a node takes effect at once (a disabled node may only renew its certificate), and deletion revokes both serials | A retired node keeps pulling configuration |
Regional probe certificates come from the same internal CA (O=Edgeweir Probe) and the node channel tells them apart by organization: probe certificates can only call ProbeService, node certificates cannot enroll or renew probes, and only enabled nodes that also probe can report results; probe tokens are single-use, expire, and are stored as SHA-256 only; a disabled probe may only renew its certificate, and deleting a probe revokes it | Leaked probe credentials are used to impersonate a node and pull configuration and keys |
| Probe results are accepted only for the prober's current targets (node, address, port) and within valid ranges; an address counts as unreachable only by a strict majority of all probers; removals by scheduling rules stay under the mass removal protection | One faulty or compromised probe takes nodes out of DNS |
The node channel checks the client certificate before it reads a request body: without one only Enroll and EnrollProbe are served, up to 64 KiB; other requests are limited to 16 MiB after decompression; connections without traffic for 2 minutes are closed | Unauthenticated clients exhaust console memory with compressed requests or idle connections |
| Certificate private keys, S3 origin keys, and PURGE keys travel only over the mTLS channel to nodes of the cluster serving the referencing site, never inside NodeConfig | Nodes of other clusters or configuration snapshots leak keys |
PURGE keys stay inside the node agent: the data plane hands the URL and X-Purge-Key of a PURGE request to the agent over a local unix socket (0600), and the agent compares them in constant time; on each node 20 per second per site and client network (an IPv6 /64; counted in a shared dict of their own) and 20 accepted requests (right key) per second per site, so clients without the key only use up their own budget, and the console counts at most 120 tasks per site and minute under a per-site lock in the transaction that inserts the task; the console creates tasks only for URLs of a site that the node's cluster serves, that has PURGE on, and whose host belongs to it | The key enters nginx shared memory or logs; PURGE used to guess the key or flood purges; a node purging for other clusters or sites |
When Set-Cookie responses are cached, the origin layer moves the cookies into an internal header, the edge layer statically hides that header in every caching location, and only a request that actually went to the origin (MISS, EXPIRED, BYPASS) gets them back; hits, stale copies and background updates never carry cookies | The cache sends one visitor's session cookie to others |
| Revision receipts are sealed with the master key and bound to the node; a node reporting a revision above the console's latest must present a valid receipt, and only verified revisions count toward revision numbering | Unauthenticated reports manipulate revision numbering after a database restore |
| Envelope encryption of secrets with the master key kept out of the database; additional authenticated data binds table, column, and record id | Database backups or read-only SQL injection leak private keys and credentials; someone with database write access swaps ciphertexts between rows |
| Management actions are audited in the same transaction as the change | Abuse or mistakes cannot be traced |
/api/auth/* serves only the better-auth endpoints the console UI uses (no sign-up, admin, or api-key endpoints), everything else returns 404; AccessKeys are created and revoked only by a signed-in session through accessKeys.*; x-api-key is dropped on /api/auth/* and /rpc and works on /api/v1 only | better-auth plugin endpoints bypass audit and revisions (creating accounts, impersonating users, changing passwords); an API key turned into a session or issuing new keys |
/rpc requires the x-csrf-token header; responses carry the CSP default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none' | Cross-site request forgery; the console embedded in a third-party page |
The client IP is the TCP peer, and forwarding headers are trusted only from EDGEWEIR_TRUSTED_PROXIES; sign-in and two-factor rate-limit counters live in PostgreSQL, shared by all instances and kept across restarts | Spoofed IPs bypass sign-in and two-factor rate limits; wrong IPs in the audit log |
install.sh and agent self-upgrades verify the cosign signature (certificate identity exactly the release workflow of the version being installed) and the SHA-256 before running anything; the console's /downloads mirror (EDGEWEIR_DOWNLOADS_DIR) is only a transport and returns 404 for files it does not hold | Tampered downloads or mirror |
Origins may not be special-purpose addresses (loopback, link-local, private, CGNAT, multicast, and so on) or localhost: the console refuses such IP literals, and nodes apply the same list to configured literals and to every DNS answer (packages/contract/src/addresses.ts); only the audited origin allow list can allow ranges; nodes send CDN-Loop (RFC 8586) upstream and answer 508 to requests that already carry their own ID | Origin fetches reach cloud metadata (169.254.169.254) or scan internal networks, or create loops |
Requests from the console to targets saved in the web UI (alert webhooks, SMTP servers, node release source, the connection check of the node channel URL) resolve the name once, reject special-purpose addresses, and connect to that address; EDGEWEIR_OUTBOUND_ALLOW_CIDRS allows specific ranges | The console's outbound requests are used to reach internal networks |
DNS providers with an entered address (PowerDNS, RFC 2136, Custom HTTP) are checked by the certificate helper on the address actually connected to; special-purpose addresses pass only inside EDGEWEIR_OUTBOUND_ALLOW_CIDRS; public addresses require HTTPS; redirects are not followed. Custom HTTP requests carry a timestamp and an HMAC-SHA256 signature; DNS provider failures come back as classified codes, never the provider's text | DNS provider settings are used to reach internal networks (including DNS rebinding), or credentials leak in cleartext or in error messages |
| A node collapses a site's purge markers into one site-level marker beyond its cap | Many purge tasks fill a node's purge store and degrade other sites on the node |
| The agent runs typed operations only and has no interface for arbitrary commands | A compromised console runs arbitrary code on nodes |
| Keyless-signed releases, SBOMs, SLSA provenance | Released programs differ from the source, or are poisoned |
Known limitations
| Limitation | Impact and handling |
|---|---|
install.sh is served by the console | Trust in the console (the operator's own server) is a precondition; for stronger assurance, download and review the script first, or compare it with the same version on GitHub |
| Compromised console | An attacker can push malicious configuration (for example, point sites to a malicious origin) but cannot make nodes run unsigned programs or obtain node private keys |
| Credentials on the node | The node keeps its private key, the S3 origin and PURGE keys (credentials.json), and site certificate private keys (certificates.json) in plain text with mode 0600 in its state directory (default /var/lib/edgeweir-node, mode 0700), so it keeps serving after a restart while the console is unreachable; root on the node can read them |
| Master key and database leaked together | Envelope encryption no longer protects the data; without BETTER_AUTH_SECRET, the leaked master key also allows forging sessions. Read it from a secret file with EDGEWEIR_MASTER_KEY_FILE (Master key file) or inject it through the orchestrator's secret mechanism, and keep it apart from database backups. After a leak, rotate the master key: rotation keeps the session secret, so a deployment without BETTER_AUTH_SECRET also sets a new one (every session ends, two-factor authentication must be enrolled again); when the database leaked too, replace the credentials stored in it |
| Setup token in the log | First-run setup needs the one-time setup token the console writes to its log at startup; anyone who can read the console log can complete setup. Restrict log access at the same level as the master key |
| Account recovery on the server | Anyone who can run commands in the console container (and so could read DATABASE_URL and change the database directly) can reset the account's password and turn two-factor authentication off with recover.js; a recovery signs out every session and is written to the audit log (account.recover), and neither the web UI nor HTTP offers recovery (Command line). Restrict server access at the same level as the master key |
| Credentials on a probe | A probe keeps its private key in plain text with mode 0600 in its state directory (default /var/lib/edgeweir-probe, mode 0700); root on the probe host can report results as that probe until it is deleted in the console |
| Health endpoint | Nodes answer GET /.edgeweir/health for any Host on every edge listener, and HTTPS handshakes with SNI health.edgeweir.invalid or without SNI get the node's self-signed certificate, so scanners can recognize Edgeweir nodes; probes do not verify that certificate, so a man in the middle on a probe's path can fake reachability, which affects scheduling but reaches no secret |
Node channel :8443 | Expose directly or pass through at layer 4 only; a reverse proxy that terminates TLS breaks mTLS (Ports, reverse proxy, and trusted proxies) |
| The node channel's WebSocket entry | /node-channel on the web port connects WebSockets to the node channel, and the handshake itself is not authenticated; the node channel's TLS runs inside the WebSocket with the same CA pinning, token, and mTLS checks as on :8443, and proxies in front of it only forward TLS records. Closed by default; it opens with EDGEWEIR_NODE_API_WEBSOCKET=true or once a wss:// or ws:// node channel URL was used, and is then reachable from the internet like :8443; browser requests with an Origin header are refused (The node channel's WebSocket entry) |
| Caching responses with Set-Cookie | With Cache responses with Set-Cookie on in a cache rule, the cached object on the node's disk keeps the fetched Set-Cookie lines (restored only for that one request); whoever reads the node's disk can read those cookies. Turn it on only for content whose body does not depend on the visitor (Set-Cookie) |
| Layer-4 forwarding | L4 app traffic does not pass the HTTP layer: WAF, rules, challenges, CC mitigation, site bans, and the global Block and Allow lists do not apply; only the app's own IP lists, the per-node connection limits, and kernel bans (kernel-ban-v1) do. Ports come only from the cluster's port pools (1024–65535), so nodes need no extra privileges. With Accept PROXY protocol on, the client address comes from the header: open that port to the load balancer only, or any client can claim another address and pass the allow lists (Layer-4 forwarding) |
| Client address of the nodes | The TCP peer by default; no request header is trusted. With a cluster's PROXY protocol source, any client that reaches a node's HTTP or HTTPS port directly can claim any address in the PROXY header, so open those ports to the load balancer only; with Trusted proxy header, only peers inside the trusted CIDRs name the visitor, so list only the proxies' own addresses. In both modes kernel bans match the TCP peer only (Client IP) |
Verifying releases
All signatures are cosign keyless signatures: the certificate identity is the release workflow of the repository, and the issuer is the GitHub Actions OIDC service https://token.actions.githubusercontent.com.
| Tool | Purpose |
|---|---|
| cosign | Verify signatures |
| GitHub CLI | Verify provenance (gh attestation verify) |
Node packages (deb, rpm, tar.gz)
-
Download the package to install,
checksums.txt, andchecksums.txt.sigstore.jsonfrom edgeweir-node Releases. -
Verify that
checksums.txtwas signed by the edgeweir-node release workflow on av*tag:cosign verify-blob \ --bundle checksums.txt.sigstore.json \ --certificate-identity-regexp '^https://github\.com/marvinli001/edgeweir-node/\.github/workflows/release\.yml@refs/tags/v.*$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ checksums.txtExpected output:
Verified OK. -
Verify the downloaded packages against
checksums.txt(on macOS, useshasum -a 256 -c):sha256sum -c checksums.txt --ignore-missingExpected output:
OKfor every downloaded file. -
Verify the build provenance (optional):
gh attestation verify edgeweir-node_<version>_linux_amd64.tar.gz --repo marvinli001/edgeweir-node
Install only after steps 2 and 3 pass. install.sh runs the same checks before it executes anything it downloaded, and is stricter: the certificate identity must equal https://github.com/marvinli001/edgeweir-node/.github/workflows/release.yml@refs/tags/v<version> exactly; without cosign on the machine, it downloads cosign v3.1.3 and checks the SHA-256 pinned in the script first. The enrollment token travels only in the EDGEWEIR_TOKEN environment variable or --token-file, never on a command line.
Console image
The console image is released on a rolling basis without version tags: every master commit that passes CI is published as <YYYYMMDD>-<first 7 characters of the commit> (for example 20260929-a1b2c3d), and latest moves only while that commit is still the tip of master. The signing certificate identity is the release workflow on the master branch.
-
Verify the signature:
cosign verify ghcr.io/marvinli001/edgeweir:<YYYYMMDD>-<commit> \ --certificate-identity https://github.com/marvinli001/edgeweir/.github/workflows/release.yml@refs/heads/master \ --certificate-oidc-issuer https://token.actions.githubusercontent.comExpected result: exit code 0.
-
Verify the build provenance:
gh attestation verify oci://ghcr.io/marvinli001/edgeweir:<YYYYMMDD>-<commit> --repo marvinli001/edgeweir -
Pin the image by digest: set
EDGEWEIR_VERSION=<YYYYMMDD>-<commit>@sha256:<digest>in.envinstead of the tag alone. Upgrades and rollback: Versions, upgrades, and rollback.
The image label org.opencontainers.image.revision holds the full commit ID.
Rebuilding from source
| Artifact | Steps |
|---|---|
| Node | Check out the release tag and run the same goreleaser build as CI with the Go version pinned in go.mod; the SHA-256 of the binaries should match the release |
| Console image | Check out the commit in org.opencontainers.image.revision; scripts/image-version.sh prints the same version; build with the release workflow's parameters (command below) |
git checkout <commit>
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) docker buildx build \
--build-arg VERSION=$(scripts/image-version.sh) \
--build-arg REVISION=$(git rev-parse HEAD) \
-t edgeweir:rebuild .The console Dockerfile's apk add tini installs the version in the Alpine repository at build time, so the image is not guaranteed to be byte-for-byte identical.