GHSA-x6fg-52vr-hj4w: NAT is a commonHandler any authenticated member can
create, and newHTTPandGRPCMux matches r.Host against NAT before dispatching
the dashboard/gRPC handlers, so a member could register the dashboard's own
host as a NAT Domain and hijack global routing. Add IsReservedDashboardHost
(InstallHost/ListenHost plus an operator-declared ReservedHosts list for
reverse-proxy deployments), reject reserved hosts on create/update, and drop
pre-planted records when building the startup cache.
GHSA-39g2-8x68-pmx8: bind-time CheckPermission let a member pre-bind a DDNS
profile ID that the victim would only create later. GetDDNSProvidersFromProfiles
now re-validates ownership by server owner UID and skips foreign-owned
profiles (UserID==0 is treated as a migration artifact, not an admin grant).
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
GHSA-x6fg-52vr-hj4w: NAT is a commonHandler any authenticated member can
create, and newHTTPandGRPCMux matches r.Host against NAT before dispatching
the dashboard/gRPC handlers, so a member could register the dashboard's own
host as a NAT Domain and hijack global routing. Add IsReservedDashboardHost
(InstallHost/ListenHost plus an operator-declared ReservedHosts list for
reverse-proxy deployments), reject reserved hosts on create/update, and drop
pre-planted records when building the startup cache.
GHSA-39g2-8x68-pmx8: bind-time CheckPermission let a member pre-bind a DDNS
profile ID that the victim would only create later. GetDDNSProvidersFromProfiles
now re-validates ownership by server owner UID and skips foreign-owned
profiles (UserID==0 is treated as a migration artifact, not an admin grant).
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.
Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.
Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.
Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.
Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.
Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.
Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
Gosec G101 treats the environment variable name NZ_JWTSECRETKEY as a hardcoded secret value. Keep the env-first JWT secret behavior unchanged and annotate the constant so CI only suppresses this false positive.
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
Gosec G101 treats the environment variable name NZ_JWTSECRETKEY as a hardcoded secret value. Keep the env-first JWT secret behavior unchanged and annotate the constant so CI only suppresses this false positive.
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
listServerGroup returned every group, including ones whose only
servers are HideForGuest=true (or that have zero members), to
unauthenticated callers. The group name itself is then leaked to
guests even though every server it references is hidden from them.
Guest visitors now only see groups that contain at least one guest-
visible server. Authenticated members and admins keep full visibility
(including their own empty groups) so management UI still works.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
listServerGroup returned every group, including ones whose only
servers are HideForGuest=true (or that have zero members), to
unauthenticated callers. The group name itself is then leaked to
guests even though every server it references is hidden from them.
Guest visitors now only see groups that contain at least one guest-
visible server. Authenticated members and admins keep full visibility
(including their own empty groups) so management UI still works.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
GET /api/v1/file?id=<server> created an FM stream on the agent stream
and committed real state change (TaskTypeFM dispatched). With JWT
cookie SameSite=Lax a victim's browser would still send the cookie on
a top-level cross-site GET, so an attacker could trick a logged-in
user into opening an FM session on any of their own servers, consuming
resources and triggering the agent's FM machinery without consent.
Mirror the GHSA-8qhj-4f8c-j8qg fix: move the route to POST. SameSite=
Lax cookies are not sent on cross-site POST. Frontend (admin-frontend)
adjusted in a follow-up commit.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
GET /api/v1/file?id=<server> created an FM stream on the agent stream
and committed real state change (TaskTypeFM dispatched). With JWT
cookie SameSite=Lax a victim's browser would still send the cookie on
a top-level cross-site GET, so an attacker could trick a logged-in
user into opening an FM session on any of their own servers, consuming
resources and triggering the agent's FM machinery without consent.
Mirror the GHSA-8qhj-4f8c-j8qg fix: move the route to POST. SameSite=
Lax cookies are not sent on cross-site POST. Frontend (admin-frontend)
adjusted in a follow-up commit.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
GHSA-vrmh-5mmx-hjwx: GET /api/v1/server/:id/service and
GET /api/v1/service/:id/history both iterate the raw service list and
emit ServiceName / timing for any service that happens to monitor the
queried server, ignoring the owner's EnableShowInService=false flag.
Both routes are on optionalAuth, so unauthenticated visitors could
enumerate hidden services by name and timing.
Introduce userCanViewService(c, service):
- EnableShowInService=true -> always visible
- admin -> always visible
- authenticated owner -> visible (HasPermission)
- everyone else -> hidden
getServiceHistory rejects unknown-or-invisible service with the same
'service not found' message so the endpoint cannot be used as an
oracle. listServerServices pre-filters the sorted service list.
Owners and admins keep their existing visibility into their own hidden
services.
Tests:
- TestUserCanViewServiceVisibleServiceIsPublic
- TestUserCanViewServiceHiddenServiceRejectsGuest
- TestUserCanViewServiceHiddenServiceRejectsForeignMember
- TestUserCanViewServiceHiddenServiceAllowsOwner
- TestUserCanViewServiceHiddenServiceAllowsAdmin
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
GHSA-vrmh-5mmx-hjwx: GET /api/v1/server/:id/service and
GET /api/v1/service/:id/history both iterate the raw service list and
emit ServiceName / timing for any service that happens to monitor the
queried server, ignoring the owner's EnableShowInService=false flag.
Both routes are on optionalAuth, so unauthenticated visitors could
enumerate hidden services by name and timing.
Introduce userCanViewService(c, service):
- EnableShowInService=true -> always visible
- admin -> always visible
- authenticated owner -> visible (HasPermission)
- everyone else -> hidden
getServiceHistory rejects unknown-or-invisible service with the same
'service not found' message so the endpoint cannot be used as an
oracle. listServerServices pre-filters the sorted service list.
Owners and admins keep their existing visibility into their own hidden
services.
Tests:
- TestUserCanViewServiceVisibleServiceIsPublic
- TestUserCanViewServiceHiddenServiceRejectsGuest
- TestUserCanViewServiceHiddenServiceRejectsForeignMember
- TestUserCanViewServiceHiddenServiceAllowsOwner
- TestUserCanViewServiceHiddenServiceAllowsAdmin
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
GHSA-8qhj-4f8c-j8qg: GET /api/v1/cron/:id/manual changed shared state
on the agent stream, and the JWT cookie is SameSite=Lax so a victim's
browser would send the cookie on a top-level cross-site GET. An
attacker could trick a logged-in user into firing any of their own
cron commands.
Switch the route to POST: SameSite=Lax cookies are not sent on cross-
site POST, closing the CSRF window without introducing a new token.
Tests:
- TestCronManualTriggerRejectsCrossSiteGET locks in that GET no longer
resolves.
- TestCronManualTriggerAcceptsSameSitePOST locks in the legitimate POST
still works.
Frontend (admin-frontend) updated in a follow-up commit.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
GHSA-8qhj-4f8c-j8qg: GET /api/v1/cron/:id/manual changed shared state
on the agent stream, and the JWT cookie is SameSite=Lax so a victim's
browser would send the cookie on a top-level cross-site GET. An
attacker could trick a logged-in user into firing any of their own
cron commands.
Switch the route to POST: SameSite=Lax cookies are not sent on cross-
site POST, closing the CSRF window without introducing a new token.
Tests:
- TestCronManualTriggerRejectsCrossSiteGET locks in that GET no longer
resolves.
- TestCronManualTriggerAcceptsSameSitePOST locks in the legitimate POST
still works.
Frontend (admin-frontend) updated in a follow-up commit.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
Replace the {user_id, ip} claim pair with {keyId, uid}:
- keyId is a 32-byte random id that points to a row in the new
jwt_sessions table holding the real user id, bound IP, UA hash,
TokenVersion and expiry.
- uid is the user id encoded through pkg/idcodec; mismatch between
claim uid and session.UserID trips WAF block on the caller IP.
- identityHandler now rejects unknown/revoked/expired sessions, IP
drift and stale TokenVersion. Refresh updates session.ExpiresAt.
User.TokenVersion bumps on password change and revokes outstanding
sessions, so a leaked JWT secret alone is no longer enough to forge
a token. JWTSession rows are GC'd every 10 minutes (expired + grace
or revoked >24h). OAuth2 callback shares the same issue path.
Includes regression tests for happy path, mismatched claim uid,
revoked session, TokenVersion bump, IP drift and unknown keyId.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
Replace the {user_id, ip} claim pair with {keyId, uid}:
- keyId is a 32-byte random id that points to a row in the new
jwt_sessions table holding the real user id, bound IP, UA hash,
TokenVersion and expiry.
- uid is the user id encoded through pkg/idcodec; mismatch between
claim uid and session.UserID trips WAF block on the caller IP.
- identityHandler now rejects unknown/revoked/expired sessions, IP
drift and stale TokenVersion. Refresh updates session.ExpiresAt.
User.TokenVersion bumps on password change and revokes outstanding
sessions, so a leaked JWT secret alone is no longer enough to forge
a token. JWTSession rows are GC'd every 10 minutes (expired + grace
or revoked >24h). OAuth2 callback shares the same issue path.
Includes regression tests for happy path, mismatched claim uid,
revoked session, TokenVersion bump, IP drift and unknown keyId.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
New pkg/idcodec wraps sqids with an alphabet derived from the JWT
secret via HKDF-SHA256 (info="nezha/idcodec/alphabet/v1"). Rotating
NZ_JWTSECRETKEY automatically reshuffles the alphabet, which doubles
as a kill switch for outstanding hashids without touching the encoder
itself.
The base alphabet drops visually-confusable characters (0/O/o/I/l/1)
and MinLength=8 hides small integer ids. Decode round-trips through
Encode to reject inputs that decode by accident under the same
alphabet.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
New pkg/idcodec wraps sqids with an alphabet derived from the JWT
secret via HKDF-SHA256 (info="nezha/idcodec/alphabet/v1"). Rotating
NZ_JWTSECRETKEY automatically reshuffles the alphabet, which doubles
as a kill switch for outstanding hashids without touching the encoder
itself.
The base alphabet drops visually-confusable characters (0/O/o/I/l/1)
and MinLength=8 hides small integer ids. Decode round-trips through
Encode to reject inputs that decode by accident under the same
alphabet.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
NZ_JWTSECRETKEY takes priority over config.yaml, env-injected keys are
not persisted and skip the version-driven rotation. When the secret is
auto-generated as a last-resort fallback, write it via a single-field
YAML patch so we never round-trip the in-memory key back to disk.
The JWTSecretKey field is marked json:"-" yaml:"-" so Save() can no
longer leak it. Update ReadEnvFile test to lock in env-over-yaml
precedence.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
NZ_JWTSECRETKEY takes priority over config.yaml, env-injected keys are
not persisted and skip the version-driven rotation. When the secret is
auto-generated as a last-resort fallback, write it via a single-field
YAML patch so we never round-trip the in-memory key back to disk.
The JWTSecretKey field is marked json:"-" yaml:"-" so Save() can no
longer leak it. Update ReadEnvFile test to lock in env-over-yaml
precedence.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
NewServerTransferClass merged Verified and acked-rollback rows and sorted
them by AckedAt only. On Windows time.Now() granularity is ~15.6ms, so
MarkVerified and an immediately-following MarkRevertDelivered routinely
share a timestamp. The stable sort then left the Verified candidate
ahead of the rollback that is actually on disk and the agent was locked
out on the next restart. Add transferID as a deterministic tiebreaker so
the later rotation always wins.
NewServerTransferClass merged Verified and acked-rollback rows and sorted
them by AckedAt only. On Windows time.Now() granularity is ~15.6ms, so
MarkVerified and an immediately-following MarkRevertDelivered routinely
share a timestamp. The stable sort then left the Verified candidate
ahead of the rollback that is actually on disk and the agent was locked
out on the next restart. Add transferID as a deterministic tiebreaker so
the later rotation always wins.
- Set GinJWTMiddleware.SigningAlgorithm to "HS256" explicitly so a future
library default change (or an alg:none confusion attempt) cannot weaken
token validation. This matches the current gin-jwt default, so behaviour
is unchanged.
- Set CookieSameSite to Lax: same as the modern-browser default, but
pinned so server-side intent is clear and CSRF on cross-site POST is
blocked while top-level GET (OAuth callback) still works.
- Move the nz-o2s OAuth2 state cookie into writeOauth2StateCookie and
set HttpOnly=true. The frontend does not read this cookie, so HttpOnly
is strictly an XSS-hardening win with no behaviour change.
JWT Cookie HttpOnly/Secure are intentionally left default for now: the
frontend reads \`!!document.cookie\` to display login state and many
deployments terminate TLS at an upstream proxy — flipping those would
require a coordinated frontend change.
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
- Set GinJWTMiddleware.SigningAlgorithm to "HS256" explicitly so a future
library default change (or an alg:none confusion attempt) cannot weaken
token validation. This matches the current gin-jwt default, so behaviour
is unchanged.
- Set CookieSameSite to Lax: same as the modern-browser default, but
pinned so server-side intent is clear and CSRF on cross-site POST is
blocked while top-level GET (OAuth callback) still works.
- Move the nz-o2s OAuth2 state cookie into writeOauth2StateCookie and
set HttpOnly=true. The frontend does not read this cookie, so HttpOnly
is strictly an XSS-hardening win with no behaviour change.
JWT Cookie HttpOnly/Secure are intentionally left default for now: the
frontend reads \`!!document.cookie\` to display login state and many
deployments terminate TLS at an upstream proxy — flipping those would
require a coordinated frontend change.
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
Auth.Check resolved client_uuid → server.ID via UUIDToID without
checking that the resolved server's UserID matched the user that the
secret was bound to. An agent presenting one user's secret could
target a different user's server UUID and impersonate it — poisoning
monitoring state, triggering alerts, or quietly receiving tasks the
real owner expected.
Add authorizeAgentForUUID: if the UUID is unknown we still allow new
registration bound to the secret owner; if the UUID is known but
points to someone else's server we reject. The rejection message
"client UUID does not belong to the agent secret owner" also helps
operators trace which user's secret has leaked.
Note: this changes runtime behaviour after batch-move/server — the
new owner must reconfigure agents with their own secret. That is the
correct contract; the previous behaviour was a cross-user reporting
bug.
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
Auth.Check resolved client_uuid → server.ID via UUIDToID without
checking that the resolved server's UserID matched the user that the
secret was bound to. An agent presenting one user's secret could
target a different user's server UUID and impersonate it — poisoning
monitoring state, triggering alerts, or quietly receiving tasks the
real owner expected.
Add authorizeAgentForUUID: if the UUID is unknown we still allow new
registration bound to the secret owner; if the UUID is known but
points to someone else's server we reject. The rejection message
"client UUID does not belong to the agent secret owner" also helps
operators trace which user's secret has leaked.
Note: this changes runtime behaviour after batch-move/server — the
new owner must reconfigure agents with their own secret. That is the
correct contract; the previous behaviour was a cross-user reporting
bug.
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>