Skip to content

fix: security audit — plugin RCE, CORS, token leak, shell exec, login rate limit - #1106

Open
wjc2821296948 wants to merge 5 commits into
siteboon:mainfrom
wjc2821296948:fix/security-audit-pr
Open

fix: security audit — plugin RCE, CORS, token leak, shell exec, login rate limit#1106
wjc2821296948 wants to merge 5 commits into
siteboon:mainfrom
wjc2821296948:fix/security-audit-pr

Conversation

@wjc2821296948

@wjc2821296948 wjc2821296948 commented Aug 5, 2026

Copy link
Copy Markdown

Summary

This PR fixes five security issues found by a manual security audit of the CloudCLI server codebase. Each commit is a self-contained fix and ships with its own PR comment below describing the issue, the failure scenario, and the resolution.

Findings

P0 — Plugin install executes npm run build on attacker-controlled repositories

server/modules/plugins/plugin-registry.service.ts cloned an arbitrary Git URL into a temp directory and ran npm run build whenever package.json declared a build script. --ignore-scripts blocks postinstall hooks but does not cover npm run build. Any party able to supply a plugin URL — including a user tricked into pasting one, or a leaked auth token — gained remote code execution on the CloudCLI host.

Fix: the build script now requires explicit opt-in (allowBuild: true) on the install/update call, after the operator has manually inspected the script. The HTTP routes accept an allowBuild field in the JSON body. A process-wide escape hatch (setAllowPluginBuildScript) is exposed for tests. See fix(plugins): disable auto-running npm run build during plugin install.

P1 — GitHub personal access token leaks via the clone-progress SSE stream

server/modules/projects/services/project-clone.service.ts embedded the supplied GitHub PAT into the clone URL (https://<token>@host/...) and forwarded git's stdout/stderr verbatim to the SSE clone-progress feed. git echoes the full URL in progress output, so the token leaked through every progress event.

Fix: run every stdout/stderr line through sanitizeGitError before relaying as progress. The function already replaces the token string with ***; the only behavior change is that the sanitized text is what the SSE consumer sees during the clone (and not only after the clone fails). See fix(projects): sanitize GitHub tokens from clone progress stream.

P1 — CORS reflects any Origin header

app.use(cors({ exposedHeaders: [...] })) was invoked with no origin option, so the cors package reflected the request's Origin header back unchanged in Access-Control-Allow-Origin for every cross-origin request. Combined with the fact that /api routes are protected by a bearer JWT that the client keeps in localStorage, any malicious site a victim visits could read responses from the server on the victim's behalf.

Fix: replace the default reflector with a callback that only allows the origin through when its host:port matches the server's own host:port. Same-origin requests (no Origin header) continue to be allowed through. Wildcard binds (0.0.0.0/::) accept any host on the configured port, preserving the LAN-hosted use case while still refusing unrelated public origins. See fix(server): restrict CORS to same host:port as the server.

P2 — System update spawns commands through sh -c

server/modules/system/system.module.ts invoked spawn('sh', ['-c', commandString], ...) for the in-app update workflow. The current templates are all literals, but the call shape is a footgun: any future change that splices appRoot, homeDirectory, an environment variable, or any operator-controlled string into the template becomes a classic shell command injection. A poisoned $PATH already substitutes a malicious npm/git binary.

Fix: split the executor into (command, args) argv arrays with shell: false. The git workflow legitimately chains three commands, so it still uses sh -c with a fully literal argument string — every other path now spawns the executable directly with no shell at all. Tests are updated to match the new argv signature. See fix(system): spawn update commands without a shell.

P2 — /api/auth/login and /api/auth/register have no rate limiting

The auth endpoints have no protection against credential stuffing or password spraying. An attacker who can reach the server (default bind 0.0.0.0:3001) can run an unbounded number of guesses per second from a single IP.

Fix: add a per-client sliding-window rate limiter (server/modules/auth/rate-limit.middleware.ts) that defaults to 10 attempts per minute and a 60-second lockout window once the cap is hit. The limiter keys on the TCP peer address (or the first X-Forwarded-For entry when behind a reverse proxy), so it scales to single-user self-hosted installs without needing a shared store. Successful and failed attempts both consume a slot; the limiter does not let a misbehaving client extend a lockout by retrying. Covered by a focused unit test. See fix(auth): rate-limit login and registration per client.

Test plan

  • npm run typecheck (pending — repo has no node_modules in this checkout)
  • npm run test
  • npm run build

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Security

    • Improved CORS validation and protected authentication endpoints with rate limiting.
    • Plugin builds are disabled by default unless explicitly enabled.
    • Sanitized Git progress and error messages to prevent token exposure.
    • Reduced risks from shell command interpretation during system updates.
  • Bug Fixes

    • Preserved update, plugin installation, and authentication behavior while improving request and command handling.
  • Tests

    • Added coverage for rate limiting and updated system update validation.

wjc2821296948 and others added 5 commits August 5, 2026 15:14
`installPluginFromGit` and `updatePluginFromGit` cloned a remote Git repository
and ran `npm run build` whenever the package.json declared a build script.
Build scripts execute arbitrary code with the server process's privileges, so
any party able to supply a plugin URL (e.g. an authenticated user tricked into
pasting a malicious URL, or a compromised auth token) gained remote code
execution on the CloudCLI host.

The build script is now opt-in: the caller must pass `allowBuild: true` to the
install/update service after manually inspecting the build command. The HTTP
`POST /api/plugins/install` and `POST /api/plugins/<name>/update` endpoints
accept an explicit `allowBuild: true` in the JSON body for that purpose. A
process-wide escape hatch (`setAllowPluginBuildScript`) is exposed for tests.

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
`startCloneProject` embeds the user-supplied GitHub personal access token into
the clone URL (https://<token>@host/...) and streams `git`'s stdout/stderr
straight into the SSE `clone-progress` feed via `onProgress`. `git` echoes the
full clone URL in its progress output, so every progress event leaks the
token to whoever is watching the feed (which includes the user, but is also
captured in any server-side logs that subscribe to the same stream).

Run every stdout/stderr line through `sanitizeGitError` before relaying as
progress. The function already replaces the token string with `***`; the only
behavior change is that the sanitized text is what the SSE consumer sees
during the clone (and not only after the clone fails).

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
`app.use(cors({ exposedHeaders: [...] }))` invoked the `cors` package with no
`origin` option, so the package reflected the request's `Origin` header back
unchanged in `Access-Control-Allow-Origin` for every cross-origin request.
Combined with the fact that most `/api` routes are only protected by a
bearer JWT that the client keeps in localStorage, any malicious site a
victim visits in the same browser could read responses from the server on
the victim's behalf by issuing requests with the victim's token.

Replace the default reflector with a callback that only allows the origin
through when its host:port matches the server's own host:port. Same-origin
requests (no Origin header) continue to be allowed through. Wildcard binds
(0.0.0.0/::) accept any host on the configured port, which preserves the
LAN-hosted use case while still refusing unrelated public origins.

Move `SERVER_PORT` / `HOST` / `DISPLAY_HOST` / `VITE_PORT` declarations above
the CORS middleware so the reflector can read them at module load time.

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
`runShellCommand` invoked `spawn('sh', ['-c', commandString], ...)`, passing
the entire command as a single shell string. The current templates are all
literals, but the call shape is a footgun: any future change that splices
`appRoot`, `homeDirectory`, an environment variable, or any operator-controlled
string into the template becomes a classic shell command injection, with the
server process's privileges. A poisoned `$PATH` would already be enough to
substitute a malicious `npm`/`git` binary into the call.

Split the executor into (command, args) argv arrays and disable the shell.
The git workflow still legitimately chains three commands, so it falls back
to `sh -c` with a fully literal argument string (no string concatenation
with external values) — every other path now spawns the executable
directly with `shell: false`.

Update the service to plan each branch as `{ command, args }` and update the
existing service tests to match the new argv signature.

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
The `/api/auth/login` and `/api/auth/register` endpoints have no protection
against credential stuffing or password spraying. An attacker who can reach
the server (default bind `0.0.0.0:3001`) can run an unbounded number of
guesses per second from a single IP. Bcrypt with 12 rounds makes each guess
slow but does not make online brute force infeasible — over a long enough
window any 8-character password falls.

Add a per-client sliding-window rate limiter that defaults to 10 attempts
per minute and a 60-second lockout window once the cap is hit. The limiter
keys on the TCP peer address (or the first `X-Forwarded-For` entry when
behind a reverse proxy), so it scales to single-user self-hosted installs
without needing a shared store. Successful and failed attempts both
consume a slot; the limiter does not let a misbehaving client extend a
lockout by retrying.

Cover the limiter with a focused unit test that verifies the under-cap,
over-cap, lockout-no-extend, rolling-window, and per-client-key behaviors.

Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The server now validates CORS origins, rate-limits authentication, controls plugin build scripts, sanitizes Git clone output, and executes system update commands with separate arguments.

Changes

Server origin validation

Layer / File(s) Summary
Configured CORS origin validation
server/index.ts
Startup constants define server hosts and ports. CORS now allows matching origins and requests without an Origin header while rejecting malformed or mismatched origins.

Authentication rate limiting

Layer / File(s) Summary
Authentication limiter and validation
server/modules/auth/rate-limit.middleware.ts, server/modules/auth/auth.routes.ts, server/modules/auth/tests/rate-limit.middleware.test.ts
Registration and login share a per-client sliding-window limiter. Tests cover allowed requests, lockouts, retry headers, expiration, and client isolation.

Plugin build policy

Layer / File(s) Summary
Explicit plugin build permission
server/modules/plugins/plugin-registry.service.ts, server/modules/plugins/plugins.routes.ts, server/modules/plugins/plugins.service.ts
Plugin builds are disabled by default. Install and update routes accept allowBuild: true and forward the option through the service layers.

Clone output sanitization

Layer / File(s) Summary
Sanitized clone progress messages
server/modules/projects/services/project-clone.service.ts
Non-empty Git clone output is sanitized before progress callbacks.

System command execution

Layer / File(s) Summary
Direct command execution contract
server/modules/system/system.module.ts, server/modules/system/system.service.ts, server/modules/system/tests/system.service.test.ts
System updates pass executables and argument arrays separately. The command runner uses spawn with shell: false. Tests verify Git, npm, and platform invocations.

Possibly related PRs

Suggested reviewers: blackmammoth

Poem

A rabbit guards the login gate,
And checks each origin at the plate.
Plugins build only when allowed,
Git whispers softly, tokens cowed.
Commands hop in pairs, shell-free—
Safer paths for you and me.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes all five security changes and clearly identifies the main fixes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/modules/auth/rate-limit.middleware.ts`:
- Around line 93-95: Update the lockout calculation in the rate-limit handling
around record.timestamps and blockedUntil so blockedUntil is at least the
earliest retained timestamp plus windowMs, while preserving the existing
lockoutMs-based delay when it is later. Keep retryAfterSeconds derived from the
final blockedUntil value so Retry-After reflects the next permitted request.

In `@server/modules/auth/tests/rate-limit.middleware.test.ts`:
- Around line 115-122: Update the timing assertion in the rate-limit test around
limiter.middleware to advance time beyond one second instead of 500 ms, then
assert the resulting Retry-After header reflects the preserved lockout rather
than a reset two-second window. Keep the existing 429 status assertion and
verify the updated header value discriminates between the two behaviors.

In `@server/modules/plugins/plugins.service.ts`:
- Around line 116-121: Update update() so dependencies.update() stages and
validates the candidate before modifying the live plugin directory or stopping a
running server; only after successful validation should the current plugin be
replaced and restarted as needed. Ensure a rejected build leaves both the live
directory and running server unchanged, and add coverage for a running plugin
with a build script updated without allowBuild: true.

In `@server/modules/projects/services/project-clone.service.ts`:
- Around line 246-251: Update the clone progress handlers around
sanitizeGitError so credential redaction remains effective when stdout or stderr
data chunks split a token across events. Maintain per-stream carry-over state,
redact only complete available content while retaining a possible token prefix,
and flush any remaining buffered content when each stream closes; add a
regression test that emits a token across two data events and verifies the
concatenated SSE progress output contains no credential fragments.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 18e2a222-ac6b-4c46-b194-7483369fbafe

📥 Commits

Reviewing files that changed from the base of the PR and between f0dca2d and 03e6876.

📒 Files selected for processing (11)
  • server/index.ts
  • server/modules/auth/auth.routes.ts
  • server/modules/auth/rate-limit.middleware.ts
  • server/modules/auth/tests/rate-limit.middleware.test.ts
  • server/modules/plugins/plugin-registry.service.ts
  • server/modules/plugins/plugins.routes.ts
  • server/modules/plugins/plugins.service.ts
  • server/modules/projects/services/project-clone.service.ts
  • server/modules/system/system.module.ts
  • server/modules/system/system.service.ts
  • server/modules/system/tests/system.service.test.ts

Comment on lines +93 to +95
if (record.timestamps.length >= maxAttempts) {
record.blockedUntil = now + lockoutMs;
const retryAfterSeconds = Math.max(1, Math.ceil(lockoutMs / 1000));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make Retry-After match the next permitted request.

If lockoutMs is shorter than windowMs, the request after blockedUntil still sees maxAttempts retained timestamps. The limiter starts another lockout, although the prior response told the client to retry. Set blockedUntil no earlier than the earliest retained timestamp plus windowMs.

Proposed fix
-      record.blockedUntil = now + lockoutMs;
-      const retryAfterSeconds = Math.max(1, Math.ceil(lockoutMs / 1000));
+      const nextAvailableAt = Math.max(
+        now + lockoutMs,
+        record.timestamps[0]! + windowMs,
+      );
+      record.blockedUntil = nextAvailableAt;
+      const retryAfterSeconds = Math.max(1, Math.ceil((nextAvailableAt - now) / 1000));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (record.timestamps.length >= maxAttempts) {
record.blockedUntil = now + lockoutMs;
const retryAfterSeconds = Math.max(1, Math.ceil(lockoutMs / 1000));
if (record.timestamps.length >= maxAttempts) {
const nextAvailableAt = Math.max(
now + lockoutMs,
record.timestamps[0]! + windowMs,
);
record.blockedUntil = nextAvailableAt;
const retryAfterSeconds = Math.max(1, Math.ceil((nextAvailableAt - now) / 1000));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/modules/auth/rate-limit.middleware.ts` around lines 93 - 95, Update
the lockout calculation in the rate-limit handling around record.timestamps and
blockedUntil so blockedUntil is at least the earliest retained timestamp plus
windowMs, while preserving the existing lockoutMs-based delay when it is later.
Keep retryAfterSeconds derived from the final blockedUntil value so Retry-After
reflects the next permitted request.

Comment on lines +115 to +122
// Advance time by half a second — still inside the lockout — and verify the
// block window does NOT extend (would happen if we kept consuming slots).
currentTime += 500;
limiter.middleware(createMockRequest('203.0.113.3') as never, response as never, next);

assert.equal(firstBlockEnd, '2');
assert.equal(response.statusCode, 429);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the lockout-extension assertion discriminate.

At 500 ms, both a preserved lockout and a reset 2-second lockout return Retry-After: 2. The test can pass when the lockout extends. Advance past one second and assert the updated header.

Proposed fix
-  currentTime += 500;
+  currentTime += 1_100;
   limiter.middleware(createMockRequest('203.0.113.3') as never, response as never, next);

   assert.equal(firstBlockEnd, '2');
   assert.equal(response.statusCode, 429);
+  assert.equal(response.headers['Retry-After'], '1');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Advance time by half a second — still inside the lockout — and verify the
// block window does NOT extend (would happen if we kept consuming slots).
currentTime += 500;
limiter.middleware(createMockRequest('203.0.113.3') as never, response as never, next);
assert.equal(firstBlockEnd, '2');
assert.equal(response.statusCode, 429);
});
// Advance time by half a second — still inside the lockout — and verify the
// block window does NOT extend (would happen if we kept consuming slots).
currentTime += 1_100;
limiter.middleware(createMockRequest('203.0.113.3') as never, response as never, next);
assert.equal(firstBlockEnd, '2');
assert.equal(response.statusCode, 429);
assert.equal(response.headers['Retry-After'], '1');
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/modules/auth/tests/rate-limit.middleware.test.ts` around lines 115 -
122, Update the timing assertion in the rate-limit test around
limiter.middleware to advance time beyond one second instead of 500 ms, then
assert the resulting Retry-After header reflects the preserved lockout rather
than a reset two-second window. Keep the existing 429 status assertion and
verify the updated header value discriminates between the two behaviors.

Comment on lines +116 to 121
async update(pluginName: string, options?: { allowBuild?: boolean }) {
validatePluginName(pluginName);
const wasRunning = dependencies.isServerRunning(pluginName);
if (wasRunning) await dependencies.stopServer(pluginName);
const plugin = normalizePluginManifest(await dependencies.update(pluginName));
const plugin = normalizePluginManifest(await dependencies.update(pluginName, options));
if (wasRunning) await startServerIfAvailable(plugin);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Keep the current plugin available when the build policy rejects an update.

If wasRunning is true, Line 119 stops the plugin server. dependencies.update() can now reject after git pull and npm install when runBuildIfNeeded denies a build. The rejection bypasses Line 121, so the plugin remains stopped. The live plugin directory can also contain the rejected update.

Stage and validate updates before replacing the live directory and stopping its server. Add a test for a running plugin whose update declares a build script without allowBuild: true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/modules/plugins/plugins.service.ts` around lines 116 - 121, Update
update() so dependencies.update() stages and validates the candidate before
modifying the live plugin directory or stopping a running server; only after
successful validation should the current plugin be replaced and restarted as
needed. Ensure a rejected build leaves both the live directory and running
server unchanged, and add coverage for a running plugin with a build script
updated without allowBuild: true.

Comment on lines +246 to 251
if (!message) return;
// `git` echoes the clone URL (with the embedded auth token) in progress
// messages. Always sanitize before forwarding to the SSE stream so the
// token is not exposed to anyone watching the clone-progress feed.
handlers.onProgress(sanitizeGitError(message, githubToken));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## locate backend standards and target files"
if [ -f .agents/skills/backend-module-standards/SKILL.md ]; then
  echo "--- backend-module-standards ---"
  sed -n '1,220p' .agents/skills/backend-module-standards/SKILL.md
else
  echo "backend-module-standards/SKILL.md not found"
fi

echo "--- target file outline/stat ---"
wc -l server/modules/projects/services/project-clone.service.ts
ast-grep outline server/modules/projects/services/project-clone.service.ts --match sanitizeGitError --view expanded || true
ast-grep outline server/modules/projects/services/project-clone.service.ts --match startCloneProject --view expanded || true

echo "--- relevant service sections ---"
sed -n '1,120p' server/modules/projects/services/project-clone.service.ts
sed -n '180,270p' server/modules/projects/services/project-clone.service.ts
sed -n '270,340p' server/modules/projects/services/project-clone.service.ts

echo "--- sanitize function references ---"
rg -n "function sanitizeGitError|const sanitizeGitError|export .*sanitizeGitError|sanitizeGitError" server -S

Repository: siteboon/claudecodeui

Length of output: 14062


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
# Read-only behavioral probe: demonstrate how splitting a secret across Node text stream chunks
# before applying the existing exact-token replace can leave fragments unreplaced.
token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz12345"
chunks = ["Some git output with ghp_1234567890abc", "defghijklmnopqrstuvwxyz12345 in the middle."]
def sanitizeGitError(message, githubToken):
    return message.replace(githubToken, "<token>")
received = ""
for chunk in chunks:
    received += sanitizeGitError(chunk, token)
print("concatenated_after_sanitize=", received)
print("contains_full_token=", token in received)
print("unredacted_fragment_count=", received.count("ghp_1") + received.count("abcdefghijklmnopqrstuvwxyz12345"))
PY

Repository: siteboon/claudecodeui

Length of output: 325


Sensitive Data Exposure (CWE-200): Exposure of Sensitive Information to an Unauthorized Actor

Reachability: External

Preserve credential redaction across stream chunks.

stdout and stderr data events are arbitrary chunks, not complete Git messages. sanitizeGitError replaces only exact token matches, so a credential split across two events is never matched and both fragments reach the clone-progress SSE stream.

Use a per-stream carry-over buffer or streaming redactor, and flush the final partial message when the stream closes. Add a regression test that splits one token across two data events and checks the concatenated progress output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/modules/projects/services/project-clone.service.ts` around lines 246 -
251, Update the clone progress handlers around sanitizeGitError so credential
redaction remains effective when stdout or stderr data chunks split a token
across events. Maintain per-stream carry-over state, redact only complete
available content while retaining a possible token prefix, and flush any
remaining buffered content when each stream closes; add a regression test that
emits a token across two data events and verifies the concatenated SSE progress
output contains no credential fragments.

@wjc2821296948

Copy link
Copy Markdown
Author

P0 — Plugin install executes npm run build on attacker-controlled repositories

Vulnerability description

installPluginFromGit(url) and updatePluginFromGit(name) in server/modules/plugins/plugin-registry.service.ts cloned an arbitrary Git URL into a temp directory and ran npm run build whenever the cloned package.json declared a build script. --ignore-scripts blocks postinstall hooks but does not cover npm run build, so the build step ran with the full privileges of the host Node process.

// server/modules/plugins/plugin-registry.service.ts (before)
// runBuildIfNeeded checked only for the existence of a build script,
// not for who authored it or what it contained.
const buildProcess = spawn('npm', ['run', 'build'], {
  cwd: dir,
  stdio: ['ignore', 'pipe', 'pipe'],
});

Any party able to supply a plugin URL — including an authenticated user tricked into pasting a hostile URL, or a leaked auth token — gained remote code execution on the CloudCLI host.

Fix approach

The build script is now opt-in. The runBuildIfNeeded helper takes a new options.allowBuild flag and a process-wide override (setAllowPluginBuildScript); if both are unset, a present build script causes the install/update to fail with a clear error. The HTTP routes POST /api/plugins/install and POST /api/plugins/<name>/update accept an explicit allowBuild: true in the JSON body for callers that have manually vetted the plugin's build command.

// server/modules/plugins/plugin-registry.service.ts (after)
if (!ALLOW_PLUGIN_BUILD_SCRIPT && !options?.allowBuild) {
  return onError(new Error(
    'Plugin declares a "build" script but plugin builds are disabled by default. ' +
    'Plugin build scripts run arbitrary code with the server process privileges. ' +
    'To install this plugin, ship a pre-built artifact and remove the build script, ' +
    'or set `allowBuild: true` after manually inspecting the build script.',
  ));
}
// server/modules/plugins/plugins.routes.ts (after)
router.post('/install', respond((req) => {
  const body = (req.body ?? {}) as { url?: unknown; allowBuild?: unknown };
  // Build scripts run arbitrary code with server privileges. Only opt in
  // when the operator has manually vetted the plugin's build script.
  const allowBuild = body.allowBuild === true;
  return service.install(body.url, { allowBuild });
}));

Referenced code

  • Commit: 55d126efix(plugins): disable auto-running npm run build during plugin install
  • Files: server/modules/plugins/plugin-registry.service.ts, server/modules/plugins/plugins.service.ts, server/modules/plugins/plugins.routes.ts

@wjc2821296948

Copy link
Copy Markdown
Author

P1 — GitHub personal access token leaks via the clone-progress SSE stream

Vulnerability description

startCloneProject in server/modules/projects/services/project-clone.service.ts embedded the user-supplied GitHub personal access token into the clone URL (https://<token>@host/...) and forwarded git's stdout/stderr verbatim to the SSE clone-progress feed via onProgress. git echoes the full clone URL in its progress output, so every progress event leaked the token to whoever was watching the feed — including the user, but also anyone whose logs subscribe to the same stream.

// server/modules/projects/services/project-clone.service.ts (before)
gitProcess.stdout?.on('data', (data: Buffer | string) => {
  const message = data.toString().trim();
  if (message) {
    handlers.onProgress(message);  // ← unfiltered, may contain https://<token>@host/...
  }
});

gitProcess.stderr?.on('data', (data: Buffer | string) => {
  const message = data.toString().trim();
  lastError = message;
  if (message) {
    handlers.onProgress(message);  // ← same leak on stderr
  }
});

Note: sanitizeGitError was already applied to lastError (line 280) — but after the same text had already been forwarded to the SSE consumer as a progress event.

Fix approach

Run every stdout/stderr line through sanitizeGitError before relaying as progress. The function already replaces the token string with ***; the only behavior change is that the sanitized text is what the SSE consumer sees during the clone (and not only after the clone fails).

// server/modules/projects/services/project-clone.service.ts (after)
gitProcess.stdout?.on('data', (data: Buffer | string) => {
  const message = data.toString().trim();
  if (!message) return;
  // `git` echoes the clone URL (with the embedded auth token) in progress
  // messages. Always sanitize before forwarding to the SSE stream so the
  // token is not exposed to anyone watching the clone-progress feed.
  handlers.onProgress(sanitizeGitError(message, githubToken));
});

gitProcess.stderr?.on('data', (data: Buffer | string) => {
  const message = data.toString().trim();
  lastError = message;
  if (!message) return;
  // Same token-leak risk on stderr. Sanitize before relaying as progress.
  handlers.onProgress(sanitizeGitError(message, githubToken));
});

Referenced code

  • Commit: 15dbf2bfix(projects): sanitize GitHub tokens from clone progress stream
  • File: server/modules/projects/services/project-clone.service.ts (lines 244-259)

@wjc2821296948

Copy link
Copy Markdown
Author

P1 — CORS reflects any Origin header

Vulnerability description

app.use(cors({ exposedHeaders: [...] })) in server/index.ts was invoked with no origin option, so the cors package reflected the request's Origin header back unchanged in Access-Control-Allow-Origin for every cross-origin request. Combined with the fact that most /api routes are only protected by a bearer JWT that the client keeps in localStorage (and which the frontend attaches to every fetch via authenticatedFetch), any malicious site a victim visits in the same browser session could read responses from the server on the victim's behalf by issuing requests with the victim's token.

// server/index.ts (before)
// Reflects any Origin header back. With JSON + bearer-token auth this is
// safe against classic CSRF (the attacker cannot read the response) but
// fails open once any XSS lands in the host page, and it makes every
// authenticated endpoint reachable from any origin in the browser.
app.use(cors({ exposedHeaders: ['X-Refreshed-Token', 'X-Auth-Error'] }));

Fix approach

Replace the default reflector with a callback that only allows the origin through when its host:port matches the server's own host:port. Same-origin requests (no Origin header — e.g. server-to-server, curl, the Electron desktop app) continue to be allowed through. Wildcard binds (0.0.0.0/::) accept any host on the configured port, which preserves the LAN-hosted use case while still refusing unrelated public origins.

// server/index.ts (after)
const corsOriginReflector = (
  origin: string | undefined,
  callback: (err: Error | null, allow?: boolean) => void,
) => {
  // No Origin header → same-origin request (e.g. server-to-server, curl);
  // these are not subject to CORS and should always be allowed through.
  if (!origin) {
    callback(null, true);
    return;
  }

  try {
    const parsed = new URL(origin);
    const requestHost = parsed.hostname;
    const requestPort = parsed.port || (parsed.protocol === 'https:' ? '443' : '80');
    const serverHost = HOST === '0.0.0.0' || HOST === '::' ? requestHost : HOST;
    const serverPort = String(SERVER_PORT);

    if (requestHost === serverHost && requestPort === serverPort) {
      callback(null, true);
      return;
    }
  } catch {
    // Malformed Origin header — refuse.
  }

  callback(null, false);
};

app.use(cors({
  origin: corsOriginReflector,
  exposedHeaders: ['X-Refreshed-Token', 'X-Auth-Error'],
}));

SERVER_PORT / HOST / DISPLAY_HOST / VITE_PORT are moved above the CORS middleware so the reflector can read them at module load time.

Referenced code

  • Commit: 990ede3fix(server): restrict CORS to same host:port as the server
  • File: server/index.ts (CORS section, plus the SERVER_PORT/HOST move)

@wjc2821296948

Copy link
Copy Markdown
Author

P2 — System update spawns commands through sh -c

Vulnerability description

runShellCommand in server/modules/system/system.module.ts invoked spawn('sh', ['-c', commandString], ...), passing the entire command as a single shell string. The current templates are all literals, but the call shape is a footgun: any future change that splices appRoot, homeDirectory, an environment variable, or any operator-controlled string into the template becomes a classic shell command injection, executed with the server process's privileges. A poisoned $PATH is already enough to substitute a malicious npm/git binary into the call.

// server/modules/system/system.module.ts (before)
function runShellCommand(
  command: string,
  workingDirectory: string,
  environment: NodeJS.ProcessEnv,
  onOutput: (output: string) => void,
  onErrorOutput: (errorOutput: string) => void,
): Promise<...> {
  return new Promise((resolve, reject) => {
    const childProcess = spawn('sh', ['-c', command], {
      cwd: workingDirectory,
      env: environment,
    });
    ...

Fix approach

Split the executor into (command, args) argv arrays and disable the shell. The git workflow legitimately chains three commands, so it falls back to sh -c with a fully literal argument string (no string concatenation with external values) — every other path now spawns the executable directly with shell: false.

// server/modules/system/system.module.ts (after)
function runShellCommand(
  command: string,
  args: string[],
  workingDirectory: string,
  environment: NodeJS.ProcessEnv,
  onOutput: (output: string) => void,
  onErrorOutput: (errorOutput: string) => void,
): Promise<...> {
  return new Promise((resolve, reject) => {
    const childProcess = spawn(command, args, {
      cwd: workingDirectory,
      env: environment,
      shell: false,
    });
    ...

The service now plans each branch as { command, args }:

// server/modules/system/system.service.ts (after)
const updatePlan = dependencies.isPlatform
  ? { command: 'npm', args: ['run', 'update:platform'] }
  : dependencies.installMode === 'git'
    ? { command: 'sh', args: ['-c', 'git checkout main && git pull && npm install'] }
    : { command: 'npm', args: ['install', '-g', '@cloudcli-ai/cloudcli@latest'] };

Existing service tests are updated to match the new argv signature.

Referenced code

  • Commit: c5bb982fix(system): spawn update commands without a shell
  • Files: server/modules/system/system.module.ts, server/modules/system/system.service.ts, server/modules/system/tests/system.service.test.ts

@wjc2821296948

Copy link
Copy Markdown
Author

P2 — /api/auth/login and /api/auth/register have no rate limiting

Vulnerability description

The auth endpoints have no protection against credential stuffing or password spraying. An attacker who can reach the server (default bind 0.0.0.0:3001) can run an unbounded number of guesses per second from a single IP. Bcrypt with 12 rounds makes each guess slow but does not make online brute force infeasible — over a long enough window any 8-character password falls.

// server/modules/auth/auth.routes.ts (before)
router.post('/login', async (req, res, next) => {
  try {
    const body = req.body as { username?: unknown; password?: unknown };
    res.json(await service.login(body.username, body.password));
  } catch (error) {
    next(error);
  }
});

Fix approach

Add a per-client sliding-window rate limiter (server/modules/auth/rate-limit.middleware.ts) that defaults to 10 attempts per minute and a 60-second lockout window once the cap is hit. The limiter keys on the TCP peer address (or the first X-Forwarded-For entry when behind a reverse proxy), so it scales to single-user self-hosted installs without needing a shared store. Successful and failed attempts both consume a slot; the limiter does not let a misbehaving client extend a lockout by retrying.

// server/modules/auth/rate-limit.middleware.ts (excerpt)
const middleware: RequestHandler = (req: Request, res: Response, next) => {
  const clientKey = readClientKey(req);
  const now = clock();
  let record = records.get(clientKey);
  if (!record) {
    record = { timestamps: [], blockedUntil: 0 };
    records.set(clientKey, record);
  }

  // An active lockout short-circuits the limiter; do not consume an attempt
  // slot so a misbehaving client cannot extend the lockout indefinitely.
  if (record.blockedUntil > now) {
    const retryAfterSeconds = Math.max(1, Math.ceil((record.blockedUntil - now) / 1000));
    res.setHeader('Retry-After', String(retryAfterSeconds));
    res.status(429).json({ success: false, error: { code: 'RATE_LIMITED', ... } });
    return;
  }

  const cutoff = now - windowMs;
  record.timestamps = record.timestamps.filter((timestamp) => timestamp > cutoff);

  if (record.timestamps.length >= maxAttempts) {
    record.blockedUntil = now + lockoutMs;
    ...res.status(429)...
    return;
  }

  record.timestamps.push(now);
  next();
};

The limiter is mounted on both /register and /login:

// server/modules/auth/auth.routes.ts (after)
router.post('/register', authRateLimit.middleware, async (req, res, next) => { ... });
router.post('/login', authRateLimit.middleware, async (req, res, next) => { ... });

A focused unit test (server/modules/auth/tests/rate-limit.middleware.test.ts) verifies the under-cap, over-cap, lockout-no-extend, rolling-window, and per-client-key behaviors.

Referenced code

  • Commit: 03e6876fix(auth): rate-limit login and registration per client
  • Files: server/modules/auth/auth.routes.ts, server/modules/auth/rate-limit.middleware.ts (new), server/modules/auth/tests/rate-limit.middleware.test.ts (new)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant