[1.4.3] - 2026-10-27
Added
- Connection limits in
[server]—header_read_timeout(NORA_HEADER_READ_TIMEOUT, 30 s: the time a client has to send a request head, and for HTTP/2 to open its first stream),keep_alive_timeout(NORA_KEEP_ALIVE_TIMEOUT, 120 s: how long an idle connection, or an HTTP/2 connection without streams, waits before it is closed) andmax_connections(NORA_MAX_CONNECTIONS, 4096: past it new connections wait in the listen backlog; keep it below the open-file limit). Akeep_alive_timeoutof 90 s or less starts with a warning: a reverse proxy keeping idle backend connections longer (traefik 90 s, nginx 60 s) would send requests into connections NORA is closing. server.maintenance_stall_secsand background-task metrics —server.maintenance_stall_secs(NORA_MAINTENANCE_STALL_SECS, 300 s, at least 90) is how long the maintenance loop may go without a finished tick before/healthanswers503.nora_task_panics_total{task}counts panics of long-lived background tasks andnora_maintenance_timeouts_totalthe maintenance ticks cut at their deadline, both exported from start at0;deploy/prometheus-rules.ymlgains aNoraTaskPanicalert. SeeMONITORING.md.
Changed
- An OIDC provider without
audiencestops the start (Action may be needed) — a provider in[[auth.oidc.providers]]withoutaudienceused to start without a warning and accept a token whatever itsaud(see Security). NORA now refuses to start with it, with an error naming the provider and exit status 78, which the packaged systemd units do not restart into, and refuses a SIGHUP reload that brings one in. Before upgrading, give every enabled provider theaudienceits CI jobs request (core.getIDToken('nora'), GitLabid_tokens: … aud:), or setany_audience = truewhere no nameableaudexists: it accepts anyaudand warns at every start. A rollback to 1.4.2 withany_audiencein the file warns about an unknown key, or does not start underNORA_CONFIG_STRICT=true. nora importimports PyPI and NuGet packages as hosted (Action may be needed) — an import from Artifactory or Nexus skipped every PyPI and NuGet artifact and still marked the repository finished, so migrated names were answered by the public upstream (see Security). PyPI files and.nupkgfiles are now stored as an upload stores them and make their project or package hosted: its index, versions and dates are built from what NORA holds and no upstream is asked for the name; a store that cannot list those records answers503rather than hand the name to the upstream. Run the import again after upgrading: a PyPI or NuGet repository that 1.4.2 or older marked finished is walked again, with nothing to delete first, and the run after it transfers nothing. An imported PyPI project lists and serves only the files imported or uploaded to it: a file cached from an upstream under its name, or uploaded by 1.4.2 or older, is not served until it is uploaded again.- Token lifetimes are capped — every mint route (
POST /api/tokens,POST /api/v1/admin/tokens, the web UI) now grants at most 3650 days, and a token minted by an OIDC identity lives no longer than its provider'smax_token_lifetime_secsor what is left of its JWT, to the second, and a JWT with nothing left mints no token. Before,ttl_dayswas taken as asked (see Security). The answer of a mint carriesexpires_in_secs, the lifetime granted;expires_in_daysstays, rounded down. Every mint leaves onetoken_mintaudit entry with the actor, the owner, the role and the lifetime, never the token. - The password lockout counts per account, and a client is one address on a dual-stack listener — the lockout counted wrong passwords per client address, so behind one NAT a few wrong passwords for one account locked every account out, and any login from it cleared them all. It now counts a username at an address; an address is still held for every username after ten accounts' worth of wrong passwords, which a login does not clear. Only passwords count: an API token sent as the Basic password, or over Bearer, is still verified during a lockout, and a valid credential refused with
403is not counted. OnNORA_HOST=::(the image default) an IPv4 peer arrived as::ffff:a.b.c.dand missedtrusted_proxies, so every client behind a loopback proxy shared one lockout and one rate-limit bucket; addresses are now IPv4 wherever they are read.X-Forwarded-Forfrom a peer outsidetrusted_proxiesis still ignored, now with one warning naming the peer. /healthanswers 503 when the server can only be fixed by a restart (Action may be needed) — a request handler that panics now answers500and the server keeps serving (see Security), and every long-lived background task runs under a supervisor that logsbackground task panicked, counts it innora_task_panics_total{task}and restarts the task after a backoff./healthanswers503withfailing_taskwhen a task keeps panicking, or when the maintenance loop has finished no tick forserver.maintenance_stall_secs(300 s); a tick waiting on a storage that does not answer is cut and fails/ready, not/health. A panic of the audit writer ends the process with exit status 70. Kubernetes restarts on the liveness probe; under Docker without Swarm, or systemd, a503restarts nothing by itself — add an autoheal watcher or a timer runningnora healthcheck || systemctl restart nora.- npm tarballs come only from where the upstream's packument says — a tarball is fetched only for a version the upstream's packument lists, at the link it names; a version it does not list is
404with no request for the tarball. A redirect on the upstream's origin is followed with its credential; one to another origin (GitHub Packages keeps files on a storage host) is followed without it, at most ten redirects for the whole chain within onenpm.proxy_timeout, the eleventh is502. Aproxiesentry bound to scopes answers their metadata only from its own origin: a redirect off it is502. A version whose tarball lies on another host is left out of the documents NORA serves, withlatestfalling back to the highest release still listed; a tarball a GitLab instance-level endpoint keeps under its project, on the upstream's origin, is served through NORA. - Cache hits cost more CPU — the provenance and integrity checks this release adds run when cached metadata is served, and for npm the cost grows with the size of the document. Measured on cache hits of a release build: p95 of a large packument such as
react(about 7 MB) went from about 10 ms to about 16 ms andexpressfrom about 2.0 ms to 3.3 ms; other packuments stay under 2 ms. On a mixed load, throughput, error rate and memory did not change; a small cached response such as a manifest, served at saturation, reaches about 9 % fewer requests per second. - Rolling back to 1.4.2 with the new
[server]keys — 1.4.2 does not knowheader_read_timeout,keep_alive_timeout,max_connectionsandmaintenance_stall_secs: it warns and ignores them, but underNORA_CONFIG_STRICT=trueit refuses to start (exit status 78). Remove them fromconfig.tomlbefore a rollback, or set them through their environment variables (NORA_HEADER_READ_TIMEOUTand the others), which 1.4.2 does not read.
Fixed
- A stored Docker blob is verified before its last byte goes out (#1169) — a blob served from storage was hashed as it streamed and its digest checked when the reader reached the end, but with
Content-Lengthdeclared hyper stops reading once that many bytes are out, so the check never ran: a blob whose bytes changed on disk reached the client as a complete200. Docker clients reject such a layer by its digest; curl and scripts did not. Stored blobs, and blobs served while a fill is in flight, now go through the verifying stream raw and Hugging Face files use: the last frame is held back until the digest is checked, so a mismatch ends the body one frame short and is logged. - A revoked token stays revoked — a revoke that landed while the
last_usedflush or the legacy-hash migration was rewriting the token file could be undone by that rewrite, and a verify that read the file just before the revoke could put the token back into the verify cache. Rewrites and removal of a token file now hold one lock, a rewrite skips a file the revoke removed, and a verify caches its result only if no revoke came in since it read the file. - Readiness tells a full, read-only or silent store from a slow one —
/healthand/readyno longer touch storage: they serve the verdict of the last maintenance tick (every 30 s), and a verdict older than 150 s counts as unreachable. On an object store the tick asks for the first page of a listing instead of listing the whole bucket, so a large bucket no longer keeps/readyat503; the store's size is counted apart. On the local disk the write probe creates a new file each time and passes only when the create, write, fsync and removal all do, so a read-only remount or a full disk turns/readyto503within one tick instead of passing on a file left from an earlier probe. A store that does not answer by the tick's deadline turns/readyto503when the tick is cut. - The quarantine counts from NORA's first fetch without trusted dates — with
trust_upstream_datesoff, npm, pub, Cargo, Go, Conan, PyPI and NuGet handed the digest quarantine the age of cached metadata (when NORA last wrote a packument, listing or registration), so metadata cached a month ago let a file first fetched now pass as mature. The quarantine now counts from the first fetch, as documented; release age is unchanged. A NuGet version is dated by the cached registration that lists it, and is undated, so counted from NORA's first sight, when none does. - A proxied PyPI file must match the
sha256its index gives — a file whose bytes have another digest is refused with502and not cached; once cached, NORA would have listed it with its own digest and the next install would have accepted it. A file the index gives no digest for is served as before. - Smaller fixes — npm tarball links an upstream writes under the other scheme of its own host (an https registry behind a TLS terminator writing
http://) are rewritten under NORA instead of sending the client past it; a RubyGems file name longer than any NORA serves is answered400before it is parsed, and parsing one of the longest servable length takes linear work; loading an htpasswd file warns once with the names of users whose hash can never match (a scheme other than bcrypt, an empty hash, a cost out of range), never the hashes.
Security
- A package could be served under a name it did not own (GHSA-54qr-w49h-mgfq) — provenance was not always bound to the bytes and the origin a package was served from. An npm publish could store a tarball for a version it did not publish; a package NORA hosted could still be fetched from the upstream; a failure partway through an import or an upload could leave a file the upstream had served marked as the project's own; a Maven import skipped a coordinate the upstream cache already held, leaving those bytes in place of the source's; and an import from Artifactory or Nexus skipped every PyPI and NuGet package, so their names were answered by the public upstream. Each lets a foreign package be served under a trusted or internal name. A publish now stores only the tarballs of the versions it publishes, a hosted name is never asked of an upstream, a name is hosted before its first file and owns only what was stored, a record is written only after its file and digest, and an imported Maven artifact replaces any object under its key that is not pinned to the source's digest.
- OIDC tokens were accepted without an audience check (GHSA-54qr-w49h-mgfq) — a provider configured without
audienceaccepted a token whatever itsaudclaim, so a token the same issuer minted for another service (GitHub Actions has one issuer for every repository) authenticated to NORA, and nothing was logged. Such a provider now stops the start and is refused on reload; see Changed. - A storage read error produced wrong but successful answers (GHSA-54qr-w49h-mgfq) — when NORA could not read its own copy of a package's metadata or integrity data (an error, not a missing key), it could answer with a PyPI index link without its
#sha256=, an npm version, tag or field left out,403for an internal package or404for one that exists — a client could install without verification or resolve an internal name elsewhere. These cases now answer503(500on an integrity violation), the upstream is not asked and no digest is left out, across npm, PyPI, NuGet, Terraform, CPAN, Conan, RubyGems and the other formats. - Token mints were not held to a lifetime ceiling (GHSA-54qr-w49h-mgfq) —
POST /api/tokensandPOST /api/v1/admin/tokenspassedttl_daysto the store unchecked: a large value made a token that never expires and a larger one wrapped around to a few hours; a token an OIDC identity minted could outlive its JWT, even one already expired within the clock leeway. Every mint route now takes its lifetime from one rule (see Changed) and is audited. - A release-age quarantine tightened by a reload was not applied (GHSA-54qr-w49h-mgfq) — for PyPI and NuGet a
min_release_agerule a SIGHUP reload brought in did not ask for release dates, so a fresh version the reloaded policy should withhold was still served. The rule now applies from the reload on, and a reload that changesquarantineorquarantine_ttl, which apply after a restart, logs a warning naming them. - Bad passwords could make good logins pay bcrypt again (GHSA-54qr-w49h-mgfq) — repeated or concurrent checks of one Basic credential each ran bcrypt, and a flood of wrong passwords could keep an accepted one from being remembered; a route served without credentials, such as
/metrics, ran bcrypt for a Basic header too. An unauthenticated client could use this to burn CPU and slow logins. Accepted and refused verdicts are now remembered apart, concurrent checks of one pair run bcrypt once, bcrypt runs off the async workers, and an open route never runs it. - Connections without a request were held open indefinitely (GHSA-54qr-w49h-mgfq) — an HTTP/1 connection that never finished its request head, and an HTTP/2 connection that opened no stream or went idle after one, were kept open with no deadline, so an unauthenticated client could exhaust connections. A connection now has
server.header_read_timeout(30 s) for its request head; an HTTP/2 connection must open its first stream within it and is closed withGOAWAYafterserver.keep_alive_timeout(120 s) without one — keep-alive pings do not extend it — andserver.max_connectionsbounds open connections. See Added. - An upstream URL rewrite could point a client at another host (GHSA-54qr-w49h-mgfq) — the npm, Ansible Galaxy and pub rewrite of the upstream's URLs into NORA's was a plain substring replace, so it also fired where the upstream URL was only a prefix of a longer host, path segment, port or userinfo (
https://<upstream>.attacker.example/…); depending on the replacement, the result led to another host or to a mangled path on NORA. The rewrite now replaces only where the upstream's URL ends, in the plain and the JSON-escaped form, and an npm version whose tarball lies outside the upstream is left out of the documents NORA serves. NuGet was not affected. - A panic ended the whole process (GHSA-54qr-w49h-mgfq) — release builds used
panic = "abort", so any panic reachable from a request or a background task stopped the server for every client. Release builds now unwind: a panic answers its request with500and the server keeps serving, and a long-lived background task is restarted (see Changed). - Upstream credentials were written to the quarantine ledger (GHSA-54qr-w49h-mgfq) — the quarantine ledger (
quarantine.jsonl) recorded the upstream URL with its userinfo in clear (https://user:pass@host/…), readable by anyone who can read the ledger or its storage. It now recordshttps://***@host/…, and a ledger an earlier release wrote is rewritten masked on the first start. Copies of the ledger taken before the upgrade still hold the credential; rotate it if they could be read. - An npm upstream's credential could follow a tarball redirect to another host (GHSA-54qr-w49h-mgfq) — a tarball was fetched with the upstream's credential (
proxy_auth, or a scope's token) through a client that dropped it only on a hop to another host or port, so when the upstream redirected a tarball to a storage host that redirected again within itself, that second hop carried the credential to the storage host. The credential now goes only while every hop is on the origin of the tarball URL (see Changed). If an npm upstream with a credential redirected its tarballs to another host, rotate the credential.
Install
# x86_64
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.3/nora-linux-amd64
chmod +x nora-linux-amd64
sudo mv nora-linux-amd64 /usr/local/bin/nora
# ARM64 (Apple Silicon, Graviton, Ampere)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.3/nora-linux-arm64
chmod +x nora-linux-arm64
sudo mv nora-linux-arm64 /usr/local/bin/noraDocker
docker pull getnora/nora:1.4.3| Variant | Image | Platforms |
|---|---|---|
| Alpine (default) | getnora/nora:1.4.3
| amd64, arm64 |
| RED OS | getnora/nora:1.4.3-redos
| amd64 |
| Astra Linux SE | getnora/nora:1.4.3-astra
| amd64 |
| GHCR | ghcr.io/getnora-io/nora:1.4.3
| amd64, arm64 |
DEB / RPM
# Debian / Ubuntu / Astra Linux (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.3/nora-amd64.deb
sudo dpkg -i nora-amd64.deb
# RHEL / Fedora / RED OS (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.3/nora-amd64.rpm
sudo rpm -i nora-amd64.rpmChangelog
See CHANGELOG.md