Commit Graph
3962 Commits
Author SHA1 Message Date
naibaandcloudcode e05700c2f0 feat(jwt): server-side session table with keyId + obfuscated uid claims
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>
2026-05-26 03:51:05 +00:00
naibaandcloudcode 7b54a2d5ea feat(jwt): server-side session table with keyId + obfuscated uid claims
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>
2026-05-26 03:51:05 +00:00
naibaandcloudcode d6f381d078 feat(idcodec): add hashid obfuscation derived from jwt secret
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>
2026-05-26 03:50:36 +00:00
naibaandcloudcode 304afd9e09 feat(idcodec): add hashid obfuscation derived from jwt secret
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>
2026-05-26 03:50:36 +00:00
naibaandcloudcode 95c3a775d5 feat(config): make jwt_secret_key env-first and never persist when injected
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>
2026-05-26 03:50:26 +00:00
naibaandcloudcode 6811f730e9 feat(config): make jwt_secret_key env-first and never persist when injected
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>
2026-05-26 03:50:26 +00:00
naiba d202ecf8db chore(frontend-templates): bump admin-frontend to v2.0.8 2026-05-25 11:42:39 +00:00
naiba 1e0f479bb5 chore(frontend-templates): bump admin-frontend to v2.0.8 2026-05-25 11:42:39 +00:00
naiba b83d93fdc9 fix(server-transfer): break loader sort ties by transferID
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.
2026-05-25 10:36:43 +00:00
naiba 3619e5551a fix(server-transfer): break loader sort ties by transferID
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.
2026-05-25 10:36:43 +00:00
naiba 6b88cdb012 feat: server transfer rotation 2026-05-25 10:17:34 +00:00
naiba e816e94fca feat: server transfer rotation 2026-05-25 10:17:34 +00:00
github-actions[bot] 37b6db806f update contributors[no ci] 2026-05-22 23:55:19 +08:00
github-actions[bot] 636f4a9716 update contributors[no ci] 2026-05-22 23:55:19 +08:00
naiba 79a6dd76f8 Update vulnerability reporting contact method 2026-05-22 22:58:18 +08:00
naiba 6612d151cd Update vulnerability reporting contact method 2026-05-22 22:58:18 +08:00
naibaandnaiba/CloudCode 8d21062c3b fix(rpc): authorize agent task results
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-21 02:02:02 +00:00
naibaandnaiba/CloudCode 02129f16fb fix(rpc): authorize agent task results
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-21 02:02:02 +00:00
naibaandnaiba/CloudCode 0f7af0bcb2 fix(rpc): bind io streams to target agents
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-21 02:01:54 +00:00
naibaandnaiba/CloudCode 8add559ec8 fix(rpc): bind io streams to target agents
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-21 02:01:54 +00:00
naibaandnaiba/CloudCode a5e2b0c36d ci: free gitee release storage before uploads
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 05:06:38 +00:00
naibaandnaiba/CloudCode 79c06d0f95 ci: free gitee release storage before uploads
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 05:06:38 +00:00
naibaandnaiba/CloudCode d8f077ffa2 ci: relax gitee release upload timeout
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 03:59:48 +00:00
naibaandnaiba/CloudCode fd08b35d81 ci: relax gitee release upload timeout
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 03:59:48 +00:00
naibaandnaiba/CloudCode d61f953c81 ci: make gitee release sync retryable
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 03:17:33 +00:00
naibaandnaiba/CloudCode 8d8fcb1275 ci: make gitee release sync retryable
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 03:17:33 +00:00
naibaandnaiba/CloudCode 1efe2bf053 fix(rpc): allow global agent secret across server owners
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 01:58:53 +00:00
naibaandnaiba/CloudCode fa31ba402e fix(rpc): allow global agent secret across server owners
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 01:58:53 +00:00
naibaandnaiba/CloudCode 9d67134148 fix(controller): filter service transfer stats by viewer
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 00:35:02 +00:00
naibaandnaiba/CloudCode 43624792a3 fix(controller): filter service transfer stats by viewer
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 00:35:02 +00:00
naibaandnaiba/CloudCode fb120074ac chore(security): pin JWT algorithm + SameSite and harden OAuth2 state cookie
- 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>
2026-05-18 15:17:59 +00:00
naibaandnaiba/CloudCode 59ccc12e58 chore(security): pin JWT algorithm + SameSite and harden OAuth2 state cookie
- 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>
2026-05-18 15:17:59 +00:00
naibaandnaiba/CloudCode 7c493e8f0b fix(rpc): verify gRPC client UUID belongs to the agent secret owner
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>
2026-05-18 15:17:37 +00:00
naibaandnaiba/CloudCode ee01a174fb fix(rpc): verify gRPC client UUID belongs to the agent secret owner
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>
2026-05-18 15:17:37 +00:00
naibaandnaiba/CloudCode 280e34977f fix(rpc): enforce magic ff05ff05 on IOStream init
The inline magic check expressed the *invalid* form as
\`byte0 != 0xff && byte1 != 0x05 && byte2 != 0xff && byte3 == 0x05\`,
relying on && to detect a four-byte mismatch. Because && short-circuits,
any payload whose byte0 happened to be 0xff was treated as a valid magic
even if the remaining bytes did not match — almost every random payload
slipped through and only the stream-UUID layer above stood between a
caller with a valid agent secret and a live IOStream session.

Extract the check into isValidIOStreamMagic stated positively (all four
bytes must match) so short-circuit reasoning cannot reintroduce the bug.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:17:18 +00:00
naibaandnaiba/CloudCode 26cf9b3fa6 fix(rpc): enforce magic ff05ff05 on IOStream init
The inline magic check expressed the *invalid* form as
\`byte0 != 0xff && byte1 != 0x05 && byte2 != 0xff && byte3 == 0x05\`,
relying on && to detect a four-byte mismatch. Because && short-circuits,
any payload whose byte0 happened to be 0xff was treated as a valid magic
even if the remaining bytes did not match — almost every random payload
slipped through and only the stream-UUID layer above stood between a
caller with a valid agent secret and a live IOStream session.

Extract the check into isValidIOStreamMagic stated positively (all four
bytes must match) so short-circuit reasoning cannot reintroduce the bug.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:17:18 +00:00
naibaandnaiba/CloudCode 097d4f7382 fix(controller): hide foreign server IDs from forceUpdateServer response
The previous loop only checked ownership inside the
\`server != nil && server.TaskStream != nil\` branch. A foreign online
server returned permission denied for the whole batch while foreign
offline / unknown IDs silently went into the Offline bucket — the
response-shape delta let a RoleMember enumerate other users' online
server IDs by submitting them in batches.

Drop both foreign and unknown IDs silently into the Offline bucket
(without dispatching the upgrade task) so the response is indistinguishable
across those three states.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:16:59 +00:00
naibaandnaiba/CloudCode 710a2c731c fix(controller): hide foreign server IDs from forceUpdateServer response
The previous loop only checked ownership inside the
\`server != nil && server.TaskStream != nil\` branch. A foreign online
server returned permission denied for the whole batch while foreign
offline / unknown IDs silently went into the Offline bucket — the
response-shape delta let a RoleMember enumerate other users' online
server IDs by submitting them in batches.

Drop both foreign and unknown IDs silently into the Offline bucket
(without dispatching the upgrade task) so the response is indistinguishable
across those three states.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:16:59 +00:00
naibaandnaiba/CloudCode 36297699f5 fix(rpc): bind io_stream sessions to creator to prevent terminal/fm hijack
createTerminal and createFM correctly check server ownership before
issuing a stream UUID, but terminalStream and fmStream only verified
that the UUID existed. Any authenticated user holding a valid stream
UUID could attach to it, gaining the original creator's live shell or
file-manager session — and the UUID is exposed via URL path (referer
leaks, access logs, browser history, frontend error reporters).

Bind the creator user ID into ioStreamContext at CreateStream time,
expose StreamOwnership and IsStreamAuthorizedForUser, and check
ownership in terminalStream/fmStream before the WebSocket upgrade so a
rejected attempt does not tear down the legitimate stream via defer.

NAT streams are also routed through CreateStream(_, 0); they are not
reachable from /ws/terminal or /ws/file so a sentinel user ID is fine.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:16:40 +00:00
naibaandnaiba/CloudCode 6661d6a7fc fix(rpc): bind io_stream sessions to creator to prevent terminal/fm hijack
createTerminal and createFM correctly check server ownership before
issuing a stream UUID, but terminalStream and fmStream only verified
that the UUID existed. Any authenticated user holding a valid stream
UUID could attach to it, gaining the original creator's live shell or
file-manager session — and the UUID is exposed via URL path (referer
leaks, access logs, browser history, frontend error reporters).

Bind the creator user ID into ioStreamContext at CreateStream time,
expose StreamOwnership and IsStreamAuthorizedForUser, and check
ownership in terminalStream/fmStream before the WebSocket upgrade so a
rejected attempt does not tear down the legitimate stream via defer.

NAT streams are also routed through CreateStream(_, 0); they are not
reachable from /ws/terminal or /ws/file so a sentinel user ID is fine.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:16:40 +00:00
naibaandnaiba/CloudCode ea7ad67f03 fix(ddns): apply SSRF defense to webhook provider
GHSA-6x26-5727-rrm9: a low-privilege member could point a DDNS webhook
at internal or loopback hosts and the dashboard would dial them with the
unrestricted utils.HttpClient.

Extract the notification SSRF defenses (CIDR blocklist, IP-pin DialContext,
SNI preservation, redirect rejection) into reusable helpers in pkg/utils
(NewRestrictedHTTPClient / ResolveAllowedHTTPURL / buildRestrictedHTTPClient)
and route the DDNS webhook through the same path. Replace the notification
inline implementation with a thin wrapper to keep behaviour identical.

Side improvements collected by the refactor:
- prepareRequest now resolves DNS once and returns the paired client, so
  the dialer's pinned IP and the validated URL stay in sync (no more
  double resolution between prepareRequest and SetRecords).
- response body is drained and closed.
- HttpClient / HttpClientSkipTlsVerify are explicitly tagged unsafe for
  attacker-controlled URLs.

Tests cover: hermetic SNI preservation, redirect rejection, dial pin to
the vetted IP, the full blocked-CIDR list at the webhook entry point,
and the verifyTLS↔skipVerifyTLS inversion in the notification wrapper.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:14:27 +00:00
naibaandnaiba/CloudCode e7c2e453c0 fix(ddns): apply SSRF defense to webhook provider
GHSA-6x26-5727-rrm9: a low-privilege member could point a DDNS webhook
at internal or loopback hosts and the dashboard would dial them with the
unrestricted utils.HttpClient.

Extract the notification SSRF defenses (CIDR blocklist, IP-pin DialContext,
SNI preservation, redirect rejection) into reusable helpers in pkg/utils
(NewRestrictedHTTPClient / ResolveAllowedHTTPURL / buildRestrictedHTTPClient)
and route the DDNS webhook through the same path. Replace the notification
inline implementation with a thin wrapper to keep behaviour identical.

Side improvements collected by the refactor:
- prepareRequest now resolves DNS once and returns the paired client, so
  the dialer's pinned IP and the validated URL stay in sync (no more
  double resolution between prepareRequest and SetRecords).
- response body is drained and closed.
- HttpClient / HttpClientSkipTlsVerify are explicitly tagged unsafe for
  attacker-controlled URLs.

Tests cover: hermetic SNI preservation, redirect rejection, dial pin to
the vetted IP, the full blocked-CIDR list at the webhook entry point,
and the verifyTLS↔skipVerifyTLS inversion in the notification wrapper.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:14:27 +00:00
naibaandnaiba/CloudCode b2294f11dd fix(ws): scope server stream visibility to viewer ownership
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 03:41:28 +00:00
naibaandnaiba/CloudCode 05e5da2535 fix(ws): scope server stream visibility to viewer ownership
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 03:41:28 +00:00
naibaandnaiba/CloudCode 2b6a1430ff chore(deps): upgrade go modules to latest
Bumps direct deps: VictoriaMetrics v1.134.0 -> v1.143.0,
gin-contrib/pprof v1.5.3 -> v1.5.4, knadh/koanf/v2 v2.3.3 -> v2.3.4,
libdns/he v1.2.1 -> v1.2.2, tidwall/gjson v1.18.0 -> v1.19.0,
golang.org/x/crypto v0.49.0 -> v0.51.0,
golang.org/x/net v0.52.0 -> v0.54.0,
google.golang.org/grpc v1.79.3 -> v1.81.1, plus transitive updates.

VictoriaMetrics v1.143.0 requires go >= 1.26.3, so the go directive
is bumped from 1.26 to 1.26.3.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 02:40:09 +00:00
naibaandnaiba/CloudCode e1244ca372 chore(deps): upgrade go modules to latest
Bumps direct deps: VictoriaMetrics v1.134.0 -> v1.143.0,
gin-contrib/pprof v1.5.3 -> v1.5.4, knadh/koanf/v2 v2.3.3 -> v2.3.4,
libdns/he v1.2.1 -> v1.2.2, tidwall/gjson v1.18.0 -> v1.19.0,
golang.org/x/crypto v0.49.0 -> v0.51.0,
golang.org/x/net v0.52.0 -> v0.54.0,
google.golang.org/grpc v1.79.3 -> v1.81.1, plus transitive updates.

VictoriaMetrics v1.143.0 requires go >= 1.26.3, so the go directive
is bumped from 1.26 to 1.26.3.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 02:40:09 +00:00
naibaandnaiba/CloudCode 854e2edf17 chore: bump admin frontend to v2.0.7
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 9f21db2dc6 chore: bump admin frontend to v2.0.7
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 7061651587 test: cover multi-user permission boundaries
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode c0e2470a9d test: cover multi-user permission boundaries
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00