Skip to content

fix(servers): 改用 location 取代已被 Hetzner 移除的 datacenter 欄位 - #52

Closed
terry90918 wants to merge 39 commits into
mainfrom
develop
Closed

fix(servers): 改用 location 取代已被 Hetzner 移除的 datacenter 欄位#52
terry90918 wants to merge 39 commits into
mainfrom
develop

Conversation

@terry90918

@terry90918 terry90918 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

問題

Hetzner Cloud API 於 2026-06-30 正式從 Servers 與 Primary IPs 資源移除 datacenter 屬性(2025-12-16 公告:Phasing out Datacenters in favor of Locations)。

HetznerServerSchemadatacenter 列為 required,formatServer 讀取 server.datacenter.location.*,導致所有 server 相關工具一律拋出:

Invalid input: expected object, received undefined (servers.0.datacenter)

影響 hetzner_list_servers / hetzner_get_server / metrics / ssh 等工具。

修法

改用 API 早已提供的頂層 location 物件,且只宣告 formatServer 實際渲染的三個欄位name / city / country)。

zod 的 z.object 預設就會剝除未宣告的多餘欄位,因此宣告得越少對上游變更越寬容;反之,一個「宣告為必要卻從未讀取」的欄位就是定時炸彈——這次的 datacenter 正是如此。

為什麼不用 .passthrough()

實測 zod 4.3.6:

多出新欄位 缺少必要欄位
預設 z.object PASS(自動剝除) THROW
.passthrough() PASS(保留) THROW

passthrough 對這次的失效模式毫無防護作用,加了只會誤導。

驗證

  • 346 unit tests / typecheck / lint 全綠
  • 新增測試以真實 API 形狀(無 datacenter、含 latitude/longitude/network_zone 等未宣告欄位)驗證 schema 可正常解析
  • runtime 實打 Hetzner APIhetzner_list_servershetzner_get_server 均回傳 **Location**: Nuremberg, DE (nbg1)

Test plan

  • bun run test
  • bun run typecheck
  • bun run lint
  • build 後對真實 Hetzner API 呼叫 hetzner_list_servers / hetzner_get_server
  • merge 後 release-please 產生 release PR → npm publish

Summary by CodeRabbit

  • 新功能
    • 新增多項儲存與伺服器相關操作的使用結果統計與空間檢查能力,並加入主機指紋驗證選項,提升連線前的安全性。
  • 錯誤修正
    • 改善啟動失敗時的錯誤顯示,避免輸出過於原始或夾帶敏感資訊。
    • 強化多處清單與詳細資訊的顯示,降低 HTML/Markdown 注入風險。
  • 文件
    • 更新說明文件與介面提示,補充破壞性操作與敏感資訊的警告。

terry90918 added 30 commits May 26, 2026 10:53
…th params (H-1)

Prevent path traversal / SSRF by guarding three URL path parameters that were
only validated with z.string().min(1):
- username in hetzner_update_storage_box_subaccount
- username in hetzner_delete_storage_box_subaccount
- snapshot_id in hetzner_delete_storage_box_snapshot

Restricts each to /^[a-zA-Z0-9._-]+$/ — same pattern used for ssh_user.
…s (H-2)

GHSA-v39h-62p7-jpjc: host confusion via percent-encoded authority delimiters
GHSA-q3j6-qgpj-74h6: path traversal via percent-encoded dot segments

bun update alone could not upgrade the transitive dep (locked by ajv range);
using package.json overrides forces fast-uri to the patched 3.1.2 release.
Remaining 8 vulnerabilities are all moderate/low via stdio-only transport paths.
…n (M-2)

Both hetzner_create_storage_box and hetzner_reset_storage_box_password
described the policy (uppercase+lowercase+digit+special) but only enforced
min(12). Weak passwords like 'aaaaaaaaaaaa' were accepted.

Added regex lookahead to enforce all four character class requirements.
Forced patched versions via package.json overrides:
- brace-expansion: 5.0.6 (GHSA-jxxr-4gwj-5jf2, DoS)
- qs: 6.15.2 (GHSA-q8mj-m7cp-5q26, DoS)
- hono: 4.12.23 (GHSA-qp7p-654g-cw7p and others)
- ip-address: 10.2.0 (GHSA-v2v4-37r5-5v8g, XSS)

bun audit now reports: No vulnerabilities found
… in tool description (M-1)

The tool uses accept-new which silently trusts new SSH host keys. If a server's
IP is reused after deletion, the tool connects to the new machine without
warning. Added a clear warning in the tool description so operators understand
the trust model and can pre-register fingerprints in known_hosts if needed.
Three review items resolved:

1. server-ssh.ts: Correct StrictHostKeyChecking=accept-new description — it
   still rejects mismatched keys for known hosts; the auto-trust only applies
   to hosts not yet in known_hosts. (Copilot review)

2. tests: Remove 'as unknown as' double cast by extending CapturedTool['opts']
   with inputSchema?: z.ZodTypeAny, making schema access type-safe without
   runtime assertions. (Copilot review)

3. storage-boxes.ts: Replace snapshot_id allowlist regex with a denylist
   refine that only blocks '/', '\\', and '..' — allowlist was too strict
   and would reject Hetzner auto-snapshot names with ISO 8601 timestamps
   (e.g. 2024-01-15T12:00:00+01:00). (Claude bot review)
…lock percent-encoded traversal

The previous .refine() denylist blocked literal "/", "\", ".." but allowed
percent-encoded variants (%2f, %5c, %2e%2e) which downstream URL parsers
can decode into path separators.

Switch to an allowlist regex /^[A-Za-z0-9._:+@-]+$/ that:
- Inherently blocks all % sequences since % is not whitelisted
- Covers all known Hetzner snapshot formats: numeric IDs, named snapshots,
  and ISO 8601 timestamps (e.g. 2024-01-15T12:00:00+01:00)

Also: change inputSchema type from z.ZodTypeAny to z.ZodType<unknown> in
tests to comply with the no-any ESLint rule (ZodTypeAny is ZodType<any>).

Adds two regression tests for %2f and %5c encoded traversal payloads.
hetzner_rollback_storage_box_snapshot's `snapshot` field only had .min(1)
and a blank-check refine, allowing path traversal and percent-encoded
variants (%2f, %5c) that the delete_snapshot's snapshot_id already guards
against.

Apply the same /^[A-Za-z0-9._:+@-]+$/ allowlist regex used by snapshot_id,
covering all known Hetzner snapshot formats (numeric IDs, named snapshots,
ISO 8601 timestamps) while blocking all path separators including encoded.

Adds 6 regression tests (3 reject, 3 accept).
Add regex guard /^(?:\d{1,3}\.){3}\d{1,3}$/ after resolving the server's
public IP from the Hetzner API response. If the API returns an unexpected
string (e.g. due to a compromised response or schema bypass), the handler
returns isError before the value reaches ssh execFile arguments.

Adds one regression test covering the invalid-format error path.
…_storage_box JSON (M-3)

Replace JSON.stringify(data) with JSON.stringify({ storage_box, action })
to prevent unexpected fields (e.g. an echoed password from a future API
change) from appearing in the JSON output.

The Zod schema already strips unknown top-level fields in production, but
the mock bypasses Zod — the test documents and enforces the intended output
contract regardless of how data arrives.
…output (L-2)

Add escapeHtml() helper to storage-boxes.ts, servers.ts, and ssh-keys.ts,
and apply it to label rendering in formatSnapshot, formatServer, and
formatSSHKey. Prevents <script> and similar payloads from appearing
unescaped in Markdown output that a MCP host might render as HTML.

Escapes: & < > " '
…torage_box_type/location (L-3)

Restrict to /^[a-z0-9-]+$/ (or /^[a-z0-9._-]+$/ for image) to prevent
unexpected characters from reaching the Hetzner API request body.
Adds early validation failure with clear error messages.

Adds 8 schema tests covering reject (special chars) and accept (valid slugs).
Previous condition (=== "production") failed to throw when NODE_ENV is
undefined, which is the typical state in a real MCP production process
where no NODE_ENV is set in the environment.

Changed to (!== "test") so the function only executes in Vitest test
runs, and throws for every other context including the production MCP
case.

RED: added test for NODE_ENV=undefined expecting throw.
GREEN: guard updated, all 279 tests pass.
…ctions (L-2b)

Applied escapeHtml() to user-controlled API-returned fields that were
previously interpolated raw into Markdown output:

- storage-boxes.ts: box.name, box.username, box.server, sub.username,
  sub.home_directory, sub.comment, snap.name, snap.description
- servers.ts: server.name, server.image.name, datacenter city/country/name
- ssh-keys.ts: key.name

Label key/value pairs were already escaped in a prior commit; this
covers all remaining heading-level and attribute fields.
289 tests pass.
…-255 (L-5)

Old regex /^(?:\d{1,3}\.){3}\d{1,3}$/ accepted invalid addresses like
'999.0.0.1' since \d{1,3} only checks digit count, not value range.

New per-octet alternation enforces 0-255:
  25[0-5] | 2[0-4]\d | [01]?\d\d?

A numerically invalid IP returned by the API now triggers the early
'unexpected format' error instead of silently failing at SSH connect time.
…ames

Old regex /^[a-z0-9._-]+$/ rejected valid Hetzner custom image names
with uppercase letters (e.g., 'Ubuntu-Hardened-2024'). All standard
system images (ubuntu-24.04, debian-12, etc.) are lowercase and continue
to pass. Injection characters (<, >, ;, space, $, /) remain blocked.

Updated description to reflect support for uppercase and numeric IDs.
- Update package name: hetzner-mcp-server → @jurislm/hetzner-mcp
- Update tool count: 20 → 40 total
- Add Cloud Volumes section (4 tools)
- Add Server Metrics section (1 tool)
- Add Server RAM via SSH section (1 tool)
- Expand Storage Boxes: 6 → 20 tools with full table
- Remove 'volumes not implemented' from cannot-do list
- Fix GitHub clone URL (jurislm/hetzner-mcp)
- Add HETZNER_API_TOKEN_UNIFIED to config examples
- Add destructive-action column to all tool tables
- Update development commands to bun
The Hetzner MCP is a standalone tool — Kamal integration is a user
choice, not part of this project. The feature/cost comparison tables
referenced third-party tools (Hatchbox, Vercel, Render) that have no
bearing on what this MCP does, adding confusion instead of clarity.
- Remove "What is MCP?" and "What is Hetzner Cloud?" tutorial sections
- Remove verbose example workflow
- Consolidate env var docs into a single table with inline explanation
- Shrink from 452 lines to 194 lines while keeping all 40 tools documented
- Fix clone/build commands: npm → bun (matches actual toolchain)
- Add Capabilities and Limitations section replacing the scattered CAN/CANNOT lists
…/MEDIUM/LOW)

H-1: extract formatStartupError() in index.ts — prevent raw AxiosError (with
     Authorization header) from being logged via console.error on startup crash

H-2: add expected_fingerprint parameter + runSshKeyScan() DI to server-ssh tool
     — gives callers a way to verify host key fingerprint before connecting and
     prevent TOFU MITM attacks on first connection

M-1/M-4: add .max(256)/.max(255) and character-allowlist regex to all filter
          params (label_selector, name, username) in list tools across
          servers.ts, volumes.ts, storage-boxes.ts — consistent with mutation
          endpoints; caps URL length and blocks anomalous query strings

M-2: apply escapeHtml() to formatVolume() fields (name, location, labels) in
     volumes.ts and to folder names in storage-boxes folder listing — matches
     the existing convention in servers.ts / ssh-keys.ts

M-3: add MCP-plaintext password warning to hetzner_create_storage_box and
     hetzner_reset_storage_box_password tool descriptions

L-1: note in hetzner_create_server description that JSON mode returns
     root_password in plaintext; advise against logging full JSON output

L-2: add @internal JSDoc to __resetClientsForTesting in api.ts

L-3: add inline comment on SSH output footer explaining that interpolation is
     safe because all three values are validated by strict Zod schemas

Tests: 322 tests passing; 25 new tests added (TDD red-green for each fix)
Findings from code-review of the security-review PR:

- Finding #1 (HIGH): runSshKeyScan now returns string[] of ALL key-type
  fingerprints; handler uses .includes() so any matching key type passes
- Finding #2 (HIGH): extraction regex updated to /SHA256:[A-Za-z0-9+/]+=*/g
  preserving base64 padding '=' chars; Zod schema already allowed '='
- Finding #3 (MEDIUM): ssh-keyscan non-zero exit now rejected even when
  partial stdout is present, preventing corrupt key material reaching ssh-keygen
- Finding #4 (MEDIUM): formatStartupError moved to src/utils.ts so
  tests/index.test.ts no longer imports src/index.ts and avoids the
  top-level main().catch(process.exit) side-effect in CI
- Finding #6 (LOW): escapeHtml in volumes.ts now uses &#x27; (matches
  servers.ts and storage-boxes.ts)

TDD: 8 new tests added (330 total), all passing.
- Move escapeHtml() from 4 tool files (servers, ssh-keys, volumes,
  storage-boxes) to src/utils.ts, eliminating 4 duplicates.
  Addresses Copilot review comment on volumes.ts escapeHtml duplication.

- Improve fingerprint verification error message from
  "could not run ssh-keyscan" to "fingerprint verification failed"
  so it accurately covers ssh-keyscan, ssh-keygen, and parse failures.
  Addresses Copilot review comment on server-ssh.ts line 104.

No behaviour change; all 330 tests pass.
…t_storage_box_space 工具

新增兩個 Storage Box 空間管理工具(issue #115):

- hetzner_get_storage_box_stats:回傳 used_bytes/used_gib、total_bytes/total_gib、
  available_gib、usage_percent(2 位小數),stats.size(data+snapshots 合計)為已用空間
- hetzner_assert_storage_box_space:接受 required_gib 參數,空間不足時回傳 isError:true,
  供備份 pipeline 在執行前做 pre-flight check
- 抽出 computeStorageBoxStats() 共用輔助函式(exported for testing)

344/344 tests pass;lint 0 error;tsc clean
Hetzner Cloud API 於 2026-06-30 正式從 Servers 與 Primary IPs 資源移除
`datacenter` 屬性(2025-12-16 公告 "Phasing out Datacenters in favor of
Locations",https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters)。

原本 HetznerServerSchema 將 `datacenter` 列為 required,且 formatServer 讀取
`server.datacenter.location.*`,導致移除後所有 hetzner_list_servers /
hetzner_get_server / metrics / ssh 相關工具一律拋出:

  Invalid input: expected object, received undefined (servers.0.datacenter)

改用 API 早已提供的頂層 `location` 物件,且**只宣告 formatServer 實際會渲染的
三個欄位**(name / city / country)。zod 的 z.object 預設就會剝除未宣告的多餘
欄位,因此宣告得越少對上游變更越寬容;反之,一個「宣告為必要卻從未讀取」的欄位
就是一顆定時炸彈——這次的 datacenter 正是如此。

why 不用 .passthrough():實測 zod 4.3.6,預設 z.object 對「多出來的欄位」本來
就 PASS(自動剝除),passthrough 只是改為保留;而兩者對「缺少必要欄位」一律
THROW。也就是說 passthrough 對這次的失效模式毫無防護作用,加了只會誤導。

驗證:
- 346 unit tests / typecheck / lint 全綠
- 新增測試以真實 API 形狀(無 datacenter、含 latitude/longitude/network_zone
  等未宣告欄位)驗證 schema 可正常解析
- runtime 實打 Hetzner API:hetzner_list_servers 與 hetzner_get_server 均
  回傳 "**Location**: Nuremberg, DE (nbg1)"
Copilot AI review requested due to automatic review settings July 8, 2026 09:35
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

本次變更新增了共用的 formatStartupErrorescapeHtml 工具函式,並套用於啟動錯誤處理與多個工具的輸出轉義;新增 SSH 主機指紋驗證(TOFU/MITM 防護);將 HetznerServerSchemadatacenter 欄位遷移為 location;強化多項工具輸入驗證與明文密碼安全提示;新增儲存盒使用量統計工具;更新文件與 CodeRabbit 設定;並移除兩個 Claude Code CI 工作流程檔案。

Changes

安全強化與工具改進

Layer / File(s) Summary
啟動錯誤格式化與共用工具函式
src/utils.ts, src/index.ts, src/api.ts, tests/index.test.ts
新增 formatStartupError/escapeHtml 匯出函式,啟動失敗時改用格式化輸出,並更新測試鉤子註解與新增測試。
SSH 主機指紋驗證(TOFU/MITM)
src/tools/server-ssh.ts, tests/tools/server-ssh.test.ts
新增 runSshKeyScan 匯出函式,hetzner_get_server_ram 新增 expected_fingerprint 輸入與指紋比對流程,並補上單元/整合測試。
伺服器 location 結構遷移
src/types.ts, src/tools/servers.ts, tests/tools/servers.test.ts, tests/tools/metrics.test.ts
HetznerServerSchema 移除 datacenter 改為頂層 location,並同步更新渲染邏輯與測試資料。
輸出 HTML 轉義統一化
src/tools/ssh-keys.ts, src/tools/storage-boxes.ts, src/tools/volumes.ts, tests/tools/volumes.test.ts, tests/tools/storage-boxes.test.ts
各工具改用共用 escapeHtml,套用於 SSH key、資料夾、卷、標籤等渲染,並新增轉義測試。
輸入驗證強化與密碼安全提示
src/tools/servers.ts, src/tools/storage-boxes.ts, src/tools/volumes.ts, tests/tools/servers.test.ts, tests/tools/storage-boxes.test.ts
多項字串欄位新增長度/格式限制,建立與重設密碼工具描述新增明文傳輸警語。
儲存盒使用量統計功能
src/tools/storage-boxes.ts, tests/tools/storage-boxes.test.ts
新增 computeStorageBoxStatshetzner_get_storage_box_statshetzner_assert_storage_box_space,並完整測試覆蓋。
破壞性操作標示與多語系文件
README.md, docs/index.html
新增「⚠️」標示說明與四語系 tools-note 翻譯文字。

CodeRabbit 與 CI 設定變更

Layer / File(s) Summary
CodeRabbit 審查語言與規則設定
.coderabbit.yaml
新增設定檔,指定審查語言為繁體中文並限定自動審查範圍。
移除 Claude Code CI 工作流程
.github/workflows/claude-code-review.yml, .github/workflows/claude.yml
完全刪除兩個既有的 Claude Code 自動審查工作流程檔案。

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • jurislm/hetzner-mcp#23: 皆修改 src/tools/storage-boxes.ts 以計算/渲染儲存盒使用率百分比。
  • jurislm/hetzner-mcp#28: 本 PR 在既有 server-ssh.ts 工具基礎上新增指紋掃描與 TOFU/MITM 驗證流程。
  • jurislm/hetzner-mcp#46: 皆圍繞 server-ssh.tsexpected_fingerprint/runSshKeyScan 驗證流程進行修改。
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 標題準確點出主要的 server schema 變更:以 location 取代已移除的 datacenter。
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch develop

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

此 PR 主要針對 Hetzner Cloud API 已移除的 datacenter 欄位進行相容性修復:將 server 相關 formatter 與 schema 改為使用頂層 location,避免所有 server 工具因 Zod required 欄位缺失而在 runtime 直接拋錯。同時也包含多處輸出 HTML escaping、filter 參數長度驗證、Storage Box 新工具(stats / space assert)、以及 server-ssh 增加 expected_fingerprint 驗證等變更。

Changes:

  • HetznerServerSchema / formatServer()datacenter.* 改為使用頂層 location.*,並新增 regression test 覆蓋「無 datacenter + 具有未知欄位」的真實 payload 形狀
  • 多個工具輸出改用共用 escapeHtml(),並補上對 filter 參數的長度/格式驗證測試
  • Storage Box 新增 stats 與空間檢查工具、server-ssh 新增 TOFU MITM 緩解的 expected_fingerprint 機制,以及調整部分文件/CI 設定

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/tools/volumes.test.ts 新增 list volumes 的 filter 驗證與 markdown escaping 測試
tests/tools/storage-boxes.test.ts 更新工具數量、補 filter/escaping 測試,新增 stats/space assert 相關測試
tests/tools/servers.test.ts 改用 location fixture;新增 location rendering 與 schema regression 測試;補 filter 驗證與 root_password 警告測試
tests/tools/server-ssh.test.ts mock child_process;新增 runSshKeyScan 與 expected_fingerprint 行為測試
tests/tools/metrics.test.ts server fixture 改用 location
tests/index.test.ts 新增 formatStartupError 單元測試
src/utils.ts 新增 formatStartupError 與共用 escapeHtml
src/types.ts HetznerServerSchema 改為 location(僅宣告 formatter 會用到的欄位)
src/tools/volumes.ts markdown 輸出欄位改 escapeHtml;filter schema 加上 max 長度限制
src/tools/storage-boxes.ts 匯入共用 escapeHtml;新增 computeStorageBoxStats 與兩個新工具;加強 filter schema;folders 輸出 escape;password 工具加 plaintext 警告
src/tools/ssh-keys.ts 移除本地 escapeHtml,改用共用 utils.escapeHtml
src/tools/servers.ts 移除本地 escapeHtml,改用共用;Location 改讀取 server.location;label_selector 加長度限制;create_server 加 plaintext root_password 警告
src/tools/server-ssh.ts 新增 runSshKeyScan;registerServerSshTools 增 DI;新增 expected_fingerprint 以降低 TOFU MITM 風險
src/index.ts 啟動錯誤輸出改用 formatStartupError(unknown)
src/api.ts 將 __resetClientsForTesting 說明改為 JSDoc @internal
README.md 工具清單區塊新增 ⚠ 標示說明
docs/index.html Tools 區塊新增 ⚠ 標示說明與多語系字串
.github/workflows/claude.yml 移除 Claude Code workflow
.github/workflows/claude-code-review.yml 移除 Claude Code Review workflow
.coderabbit.yaml 新增 CodeRabbit 設定(語言/語氣/忽略 release PR)

Comment on lines +1322 to +1325
inputSchema: z.object({
id: z.number().int().positive().describe("The Storage Box ID"),
required_gib: z.number().positive().describe("Minimum required free space in GiB")
}).strict(),
Comment on lines +1338 to +1353
if (stats.available_gib >= params.required_gib) {
return {
content: [{
type: "text",
text: `✓ Storage Box ${params.id} has sufficient space: ${stats.available_gib.toFixed(2)} GiB available (required: ${params.required_gib} GiB, usage: ${stats.usage_percent.toFixed(2)}%).`
}]
};
}

return {
content: [{
type: "text",
text: `✗ Storage Box ${params.id} has insufficient space: ${stats.available_gib.toFixed(2)} GiB available but ${params.required_gib} GiB required (usage: ${stats.usage_percent.toFixed(2)}%, total: ${stats.total_gib.toFixed(2)} GiB).`
}],
isError: true
};
Comment on lines +1257 to +1261
// Get Storage Box Stats
server.registerTool(
"hetzner_get_storage_box_stats",
{
title: "Get Storage Box Stats",
@terry90918

Copy link
Copy Markdown
Collaborator Author

改由 fix/hetzner-datacenter-removal 重開:本 PR 的 head develop 落後 main 16 個 commit(含多項 security hardening),直接合併會回退 main 上的安全修正,且產生 18 個衝突區塊落在 SSH host-key pinning 與 path-traversal 防護等敏感程式碼。新分支直接基於 origin/main,cherry-pick 同一個修正,零衝突。

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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 `@src/tools/server-ssh.ts`:
- Around line 231-250: The fingerprint check in the server SSH flow is only a
preflight scan and is not tied to the actual SSH session. Update the logic
around the `keyScanRunner` / `runSsh` path so the verified host key is written
to a temporary known_hosts file, then make `runSsh` use that file with
`UserKnownHostsFile` and `StrictHostKeyChecking=yes` instead of `accept-new`.
Keep the existing fingerprint mismatch/error handling, but ensure the live SSH
connection is validated against the same verified key.

In `@src/tools/storage-boxes.ts`:
- Around line 1322-1325: `hetzner_assert_storage_box_space` 的 `inputSchema`
目前只接受 `id` 和 `required_gib`,需補上共用的 `ResponseFormatSchema`,讓介面與
`hetzner_get_storage_box_stats` 一致並符合工具規範。請在該工具的 schema 中加入 `response_format`(預設
markdown),並在對應的 handler/執行邏輯中依 `ResponseFormat.JSON` 分支回傳結構化結果。

In `@tests/tools/server-ssh.test.ts`:
- Around line 469-500: Update the test case name in server-ssh.test.ts so it
matches the actual assertion in the padded fingerprint scenario: the current
title in the padded fingerprint test suggests it should return isError, but the
check in the captureHandler test expects success. Keep the assertion as-is and
rename the test to describe that expected_fingerprint with padding matches
correctly in runSshKeyScan/captureHandler, so the intent is clear to future
readers.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4703ddf2-0caf-4f5d-b7dc-44d86f323f7d

📥 Commits

Reviewing files that changed from the base of the PR and between b83e7fe and 0ca4736.

📒 Files selected for processing (20)
  • .coderabbit.yaml
  • .github/workflows/claude-code-review.yml
  • .github/workflows/claude.yml
  • README.md
  • docs/index.html
  • src/api.ts
  • src/index.ts
  • src/tools/server-ssh.ts
  • src/tools/servers.ts
  • src/tools/ssh-keys.ts
  • src/tools/storage-boxes.ts
  • src/tools/volumes.ts
  • src/types.ts
  • src/utils.ts
  • tests/index.test.ts
  • tests/tools/metrics.test.ts
  • tests/tools/server-ssh.test.ts
  • tests/tools/servers.test.ts
  • tests/tools/storage-boxes.test.ts
  • tests/tools/volumes.test.ts
💤 Files with no reviewable changes (2)
  • .github/workflows/claude-code-review.yml
  • .github/workflows/claude.yml

Comment thread src/tools/server-ssh.ts
Comment on lines +231 to +250
// Step 2: verify host fingerprint if caller supplied one
if (params.expected_fingerprint) {
let actualFps: string[];
try {
actualFps = await keyScanRunner(ipv4, sshPort);
} catch (scanErr) {
return {
content: [{ type: "text", text: `Error: fingerprint verification failed: ${scanErr instanceof Error ? scanErr.message : String(scanErr)}` }],
isError: true
};
}
if (!actualFps.includes(params.expected_fingerprint)) {
return {
content: [{ type: "text", text: `Error: fingerprint mismatch for ${ipv4}. Expected: ${params.expected_fingerprint} — Got: ${actualFps.join(", ")}` }],
isError: true
};
}
}

// Step 3: SSH and run free -m

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect relevant file and surrounding context
wc -l src/tools/server-ssh.ts
sed -n '1,260p' src/tools/server-ssh.ts

# Find definitions/usages of keyScanRunner and runSsh
rg -n "keyScanRunner|runSsh|StrictHostKeyChecking|UserKnownHostsFile|known_hosts|accept-new" src/tools/server-ssh.ts src -g '!dist' -g '!build'

Repository: jurislm/hetzner-mcp

Length of output: 11293


將指紋驗證綁定到實際 SSH 連線
ssh-keyscan 這段比對目前只是前置檢查,runSsh 仍用 StrictHostKeyChecking=accept-new 開啟獨立連線;主動式 MITM 仍可能讓驗證與真正連線看到不同的 host key。建議把已驗證的 key 寫入暫存 known_hosts,並讓實際連線改用 -o UserKnownHostsFile=<tmpfile> -o StrictHostKeyChecking=yes

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile } from "child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 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 `@src/tools/server-ssh.ts` around lines 231 - 250, The fingerprint check in the
server SSH flow is only a preflight scan and is not tied to the actual SSH
session. Update the logic around the `keyScanRunner` / `runSsh` path so the
verified host key is written to a temporary known_hosts file, then make `runSsh`
use that file with `UserKnownHostsFile` and `StrictHostKeyChecking=yes` instead
of `accept-new`. Keep the existing fingerprint mismatch/error handling, but
ensure the live SSH connection is validated against the same verified key.

Comment on lines +1322 to +1325
inputSchema: z.object({
id: z.number().int().positive().describe("The Storage Box ID"),
required_gib: z.number().positive().describe("Minimum required free space in GiB")
}).strict(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

hetzner_assert_storage_box_space 缺少 response_format 參數。

inputSchema 只有 idrequired_gib,未提供共用的 ResponseFormatSchema,與同批新增的 hetzner_get_storage_box_stats 行為不一致。

♻️ 建議補上 response_format
       inputSchema: z.object({
         id: z.number().int().positive().describe("The Storage Box ID"),
-        required_gib: z.number().positive().describe("Minimum required free space in GiB")
+        required_gib: z.number().positive().describe("Minimum required free space in GiB"),
+        response_format: ResponseFormatSchema.describe("Output format: 'markdown' or 'json'")
       }).strict(),

如採用,記得在 handler 內加上 ResponseFormat.JSON 分支輸出結構化結果。

As per coding guidelines:「每個工具應接受 response_format: "markdown" | "json" 參數(預設為 "markdown"),使用共用的 ResponseFormatSchema」。

📝 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
inputSchema: z.object({
id: z.number().int().positive().describe("The Storage Box ID"),
required_gib: z.number().positive().describe("Minimum required free space in GiB")
}).strict(),
inputSchema: z.object({
id: z.number().int().positive().describe("The Storage Box ID"),
required_gib: z.number().positive().describe("Minimum required free space in GiB"),
response_format: ResponseFormatSchema.describe("Output format: 'markdown' or 'json'")
}).strict(),
🤖 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 `@src/tools/storage-boxes.ts` around lines 1322 - 1325,
`hetzner_assert_storage_box_space` 的 `inputSchema` 目前只接受 `id` 和
`required_gib`,需補上共用的 `ResponseFormatSchema`,讓介面與
`hetzner_get_storage_box_stats` 一致並符合工具規範。請在該工具的 schema 中加入 `response_format`(預設
markdown),並在對應的 handler/執行邏輯中依 `ResponseFormat.JSON` 分支回傳結構化結果。

Source: Coding guidelines

Comment on lines +469 to +500
it("proceeds when expected_fingerprint matches second key in multi-key response (Finding #1)", async () => {
mockedRequest.mockResolvedValueOnce(serverResponse);
// keyScanRunner now returns string[] — expected is the SECOND fingerprint
const multiKeyMock = vi.fn<typeof runSshKeyScan>().mockResolvedValueOnce([WRONG_FP, FAKE_FP]);
mockSsh.mockResolvedValueOnce(FREE_OUTPUT_NORMAL);

const result = await captureHandler(multiKeyMock)({
id: 1,
expected_fingerprint: FAKE_FP,
response_format: "markdown"
});

expect(result.isError).toBeUndefined();
expect(mockSsh).toHaveBeenCalled();
});

it("returns isError when padded fingerprint (SHA256:abc==) is expected but extraction strips padding (Finding #2)", async () => {
const PADDED_FP = "SHA256:AbCdEfGhIjKlMnOpQrStUvWxYzABCDEFGHIJK==";
mockedRequest.mockResolvedValueOnce(serverResponse);
const paddedMock = vi.fn<typeof runSshKeyScan>().mockResolvedValueOnce([PADDED_FP]);
mockSsh.mockResolvedValueOnce(FREE_OUTPUT_NORMAL);

const result = await captureHandler(paddedMock)({
id: 1,
expected_fingerprint: PADDED_FP,
response_format: "markdown"
});

// Should succeed — padded fingerprint in response must match padded expected
expect(result.isError).toBeUndefined();
expect(mockSsh).toHaveBeenCalled();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

測試標題與斷言內容不符,容易誤導維護者。

it("returns isError when padded fingerprint (SHA256:abc==) is expected but extraction strips padding (Finding #2)", ...)(Line 485)標題暗示「應回傳 isError」,但實際斷言卻是 expect(result.isError).toBeUndefined()(Line 498),驗證的其實是「padding 有正確保留、比對成功」的情境。建議修正標題以反映真實行為,避免日後誤解測試意圖。

📝 建議修正測試標題
-  it("returns isError when padded fingerprint (SHA256:abc==) is expected but extraction strips padding (Finding `#2`)", async () => {
+  it("succeeds when padded fingerprint (SHA256:abc==) is preserved and matches expected (Finding `#2` regression)", async () => {
📝 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
it("proceeds when expected_fingerprint matches second key in multi-key response (Finding #1)", async () => {
mockedRequest.mockResolvedValueOnce(serverResponse);
// keyScanRunner now returns string[] — expected is the SECOND fingerprint
const multiKeyMock = vi.fn<typeof runSshKeyScan>().mockResolvedValueOnce([WRONG_FP, FAKE_FP]);
mockSsh.mockResolvedValueOnce(FREE_OUTPUT_NORMAL);
const result = await captureHandler(multiKeyMock)({
id: 1,
expected_fingerprint: FAKE_FP,
response_format: "markdown"
});
expect(result.isError).toBeUndefined();
expect(mockSsh).toHaveBeenCalled();
});
it("returns isError when padded fingerprint (SHA256:abc==) is expected but extraction strips padding (Finding #2)", async () => {
const PADDED_FP = "SHA256:AbCdEfGhIjKlMnOpQrStUvWxYzABCDEFGHIJK==";
mockedRequest.mockResolvedValueOnce(serverResponse);
const paddedMock = vi.fn<typeof runSshKeyScan>().mockResolvedValueOnce([PADDED_FP]);
mockSsh.mockResolvedValueOnce(FREE_OUTPUT_NORMAL);
const result = await captureHandler(paddedMock)({
id: 1,
expected_fingerprint: PADDED_FP,
response_format: "markdown"
});
// Should succeed — padded fingerprint in response must match padded expected
expect(result.isError).toBeUndefined();
expect(mockSsh).toHaveBeenCalled();
});
it("proceeds when expected_fingerprint matches second key in multi-key response (Finding `#1`)", async () => {
mockedRequest.mockResolvedValueOnce(serverResponse);
// keyScanRunner now returns string[] — expected is the SECOND fingerprint
const multiKeyMock = vi.fn<typeof runSshKeyScan>().mockResolvedValueOnce([WRONG_FP, FAKE_FP]);
mockSsh.mockResolvedValueOnce(FREE_OUTPUT_NORMAL);
const result = await captureHandler(multiKeyMock)({
id: 1,
expected_fingerprint: FAKE_FP,
response_format: "markdown"
});
expect(result.isError).toBeUndefined();
expect(mockSsh).toHaveBeenCalled();
});
it("succeeds when padded fingerprint (SHA256:abc==) is preserved and matches expected (Finding `#2` regression)", async () => {
const PADDED_FP = "SHA256:AbCdEfGhIjKlMnOpQrStUvWxYzABCDEFGHIJK==";
mockedRequest.mockResolvedValueOnce(serverResponse);
const paddedMock = vi.fn<typeof runSshKeyScan>().mockResolvedValueOnce([PADDED_FP]);
mockSsh.mockResolvedValueOnce(FREE_OUTPUT_NORMAL);
const result = await captureHandler(paddedMock)({
id: 1,
expected_fingerprint: PADDED_FP,
response_format: "markdown"
});
// Should succeed — padded fingerprint in response must match padded expected
expect(result.isError).toBeUndefined();
expect(mockSsh).toHaveBeenCalled();
});
🤖 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 `@tests/tools/server-ssh.test.ts` around lines 469 - 500, Update the test case
name in server-ssh.test.ts so it matches the actual assertion in the padded
fingerprint scenario: the current title in the padded fingerprint test suggests
it should return isError, but the check in the captureHandler test expects
success. Keep the assertion as-is and rename the test to describe that
expected_fingerprint with padding matches correctly in
runSshKeyScan/captureHandler, so the intent is clear to future readers.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants