Edgeweir
Project

Security policy

Vulnerability reporting, supported versions, trust baseline, threat controls, and release verification for edgeweir and edgeweir-node.

简体中文:SECURITY.md

Scope

SubjectLocation
Console sourcemarvinli001/edgeweir
Node sourcemarvinli001/edgeweir-node
Console imageghcr.io/marvinli001/edgeweir
Node release artifactsedgeweir-node Releases

Reporting a vulnerability

Do not report security vulnerabilities through public issues, discussions, or pull requests.

ChannelAddress
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:

ItemContents
Component and versionAffected component; image version, release version, or commit hash
ReproductionSteps to reproduce or proof-of-concept code
ImpactWhat an attacker can achieve and the preconditions
Disclosure stateWhether the issue is public or known to be exploited

Handling:

  1. Receipt of the report is acknowledged within 3 working days.
  2. After assessment, the reporter receives a preliminary conclusion and a fix plan, with progress updates during handling.
  3. 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.
  4. 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

ComponentSupportedNot supported
ConsoleThe latest rolling version: the <YYYYMMDD>-<commit> image of the newest master commit (latest)Earlier rolling versions
edgeweir-nodeThe latest masterOther 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.

RuleContents
No phone-homeThe 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 checksThe code has no license keys, online activation, or feature locks
Telemetry off by defaultNo 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 secretsPrivate 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 storedThe 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 auditedSee Audit log
Verifiable releasescosign 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

ItemValue
Master keyEDGEWEIR_MASTER_KEY: base64 of at least 32 random bytes (openssl rand -base64 32); never stored in the database
Master key formatCanonical 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 idEnvelopes 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 rotationEDGEWEIR_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 keyHKDF-SHA256, salt edgeweir/kek/v1, info envelope, 32 bytes
Data keyRandom per record; data and data key both encrypted with AES-256-GCM
Additional authenticated dataedgeweir/envelope/v2, <table>.<column>, <record id>; a ciphertext moved to another row or column fails to decrypt
Format versionv2; v1 envelopes written by older versions (bound to the purpose only) are re-encrypted at console startup, and the read path rejects v1

Storage

DataStorageLocation
Internal CA private keyEnvelope-encryptedpki_authority.private_key_envelope
Certificate private keysEnvelope-encryptedcertificate.private_key_envelope
ACME accountsEnvelope-encryptedacme_account.account_envelope; the EAB key of a request in certificate.account_envelope
ACME DNS-01 credentialsEnvelope-encrypteddns_credential.credential_envelope
DNS steering provider credentialsEnvelope-encryptedplatform_dns_provider.credential_envelope
S3 origin keysEnvelope-encryptedorigin_credential.secret_envelope
Site PURGE keysEnvelope-encryptedsite_secret.secret_envelope
Alert channel configuration (webhook URL and bearer token, email recipients)Envelope-encryptedalert_channel.config_envelope
SMTP settings (including the password)Envelope-encryptednotification_smtp in system_setting
Setup tokenEnvelope-encrypted; SHA-256 kept for comparisonsetup_token in system_setting
Challenge page signing keysEnvelope-encryptedchallenge_key.secret
Node enrollment tokensSHA-256enrollment_token
Probe enrollment tokensSHA-256probe_token
AccessKeysHash (better-auth)apikey
User passwordsscrypt hash (better-auth)account
TOTP secrets and backup codesEncrypted with the session secret (better-auth)two_factor
Session secretNot stored, only an HMAC-SHA256 check value; after a master key rotation, the value derived from the old key is stored envelope-encryptedauth_secret_check and auth_secret in system_setting
Master keyNot storedEnvironment 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.

CaseBehavior
BETTER_AUTH_SECRET set (at least 32 characters)That value is used
BETTER_AUTH_SECRET unsetDerived 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 keyThat 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 changesThe console starts and logs a warning; existing sessions end and enrolled two-factor secrets can no longer be read

Audit log

ItemBehavior
CoverageWrites 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
TransactionsEdgeweir'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
ContentsNo plain-text passwords, tokens, or keys
Source IPThe TCP peer; X-Forwarded-For and X-Real-IP are used only when the peer is in EDGEWEIR_TRUSTED_PROXIES
ChangesThe application offers only queries; there is no interface to modify or delete audit entries

Threats and controls

ControlThreat
The console never stores SSH credentials; nodes join only through the one-time install command and enroll themselvesA 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 certificatesA 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 onlyMan-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 serialsA 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 itLeaked 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 protectionOne 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 closedUnauthenticated 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 NodeConfigNodes 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 itThe 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 cookiesThe 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 numberingUnauthenticated 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 idDatabase 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 changeAbuse 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 onlybetter-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 restartsSpoofed 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 holdTampered 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 IDOrigin 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 rangesThe 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 textDNS 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 capMany 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 commandsA compromised console runs arbitrary code on nodes
Keyless-signed releases, SBOMs, SLSA provenanceReleased programs differ from the source, or are poisoned

Known limitations

LimitationImpact and handling
install.sh is served by the consoleTrust 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 consoleAn 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 nodeThe 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 togetherEnvelope 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 logFirst-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 serverAnyone 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 probeA 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 endpointNodes 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 :8443Expose 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-CookieWith 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 forwardingL4 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 nodesThe 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.

ToolPurpose
cosignVerify signatures
GitHub CLIVerify provenance (gh attestation verify)

Node packages (deb, rpm, tar.gz)

  1. Download the package to install, checksums.txt, and checksums.txt.sigstore.json from edgeweir-node Releases.

  2. Verify that checksums.txt was signed by the edgeweir-node release workflow on a v* 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.txt

    Expected output: Verified OK.

  3. Verify the downloaded packages against checksums.txt (on macOS, use shasum -a 256 -c):

    sha256sum -c checksums.txt --ignore-missing

    Expected output: OK for every downloaded file.

  4. 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.

  1. 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.com

    Expected result: exit code 0.

  2. Verify the build provenance:

    gh attestation verify oci://ghcr.io/marvinli001/edgeweir:<YYYYMMDD>-<commit> --repo marvinli001/edgeweir
  3. Pin the image by digest: set EDGEWEIR_VERSION=<YYYYMMDD>-<commit>@sha256:<digest> in .env instead 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

ArtifactSteps
NodeCheck 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 imageCheck 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.

Edit on GitHub

On this page