diff --git a/.github/workflows/sync-existing-prs.yml b/.github/workflows/sync-existing-prs.yml new file mode 100644 index 00000000..817c706f --- /dev/null +++ b/.github/workflows/sync-existing-prs.yml @@ -0,0 +1,281 @@ +name: Sync Existing PRs to Feishu Bitable + +on: + workflow_dispatch: + inputs: + state: + description: 'PR state to sync' + required: true + default: 'all' + type: choice + options: + - all + - open + - closed + - merged + limit: + description: 'Max number of PRs to sync (0 = all)' + required: false + default: '0' + type: string + +jobs: + sync-existing: + runs-on: ubuntu-latest + concurrency: + group: feishu-sync-existing + cancel-in-progress: false + env: + LARK_APP_ID: ${{ secrets.LARK_APP_ID }} + LARK_APP_SECRET: ${{ secrets.LARK_APP_SECRET }} + BITABLE_APP_TOKEN: ${{ secrets.BITABLE_APP_TOKEN }} + BITABLE_TABLE_ID: ${{ secrets.BITABLE_TABLE_ID }} + steps: + - name: Check secrets + run: | + if [ -z "$LARK_APP_SECRET" ]; then + echo "::error::LARK_APP_SECRET is not set, skipping." + exit 1 + fi + + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Install lark-cli + run: npm install -g @larksuite/cli + + - name: Configure lark-cli + shell: bash + run: | + echo "$LARK_APP_SECRET" | lark-cli config init --app-id "$LARK_APP_ID" --brand feishu --app-secret-stdin + lark-cli auth status + + - name: Fetch and sync PRs + shell: bash + env: + GH_TOKEN: ${{ github.token }} + STATE: ${{ inputs.state }} + LIMIT: ${{ inputs.limit }} + REPO: ${{ github.repository }} + run: | + set -eo pipefail + + GH_ARGS=("--json" "number,title,state,isDraft,author,headRefName,baseRefName,headRefOid,url,body,createdAt,updatedAt,closedAt,mergedAt,mergedBy,labels,milestone,assignees,reviewRequests") + + if [ "$STATE" = "merged" ]; then + GH_ARGS+=("--state" "closed" "--search" "is:merged") + elif [ "$STATE" != "all" ]; then + GH_ARGS+=("--state" "$STATE") + else + GH_ARGS+=("--state" "all") + fi + + if [ "$LIMIT" != "0" ] && [ -n "$LIMIT" ]; then + GH_ARGS+=("--limit" "$LIMIT") + else + GH_ARGS+=("--limit" "9999") + fi + + echo "Fetching PRs with: gh pr list ${GH_ARGS[*]}" + PRS=$(gh pr list "${GH_ARGS[@]}") + TOTAL=$(echo "$PRS" | jq 'length') + echo "Found $TOTAL PRs to sync" + + fmt_date() { + local raw="$1" + [ -z "$raw" ] || [ "$raw" = "null" ] && echo "" && return + date -d "$raw" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$raw" + } + + FAIL_COUNT=0 + SUCCESS_COUNT=0 + + while read -r PR; do + PR_NUMBER=$(echo "$PR" | jq -r '.number') + echo "--- Processing PR #$PR_NUMBER ---" + + COMMITS=0 + ADDITIONS=0 + DELETIONS=0 + CHANGED_FILES=0 + + STATS_OK=true + PR_DETAIL="" + for attempt in 1 2 3; do + if PR_DETAIL=$(gh pr view "$PR_NUMBER" --json "additions,deletions,changedFiles,commits" 2>/dev/null); then + break + fi + echo " gh pr view attempt $attempt failed, retrying..." + sleep 2 + if [ "$attempt" -eq 3 ]; then + echo " ::warning::gh pr view failed after 3 attempts for PR #$PR_NUMBER" + STATS_OK=false + fi + done + + if [ "$STATS_OK" = true ] && [ -n "$PR_DETAIL" ]; then + COMMITS=$(echo "$PR_DETAIL" | jq -r '.commits | length // 0' 2>/dev/null) || COMMITS=0 + ADDITIONS=$(echo "$PR_DETAIL" | jq -r '.additions // 0' 2>/dev/null) || ADDITIONS=0 + DELETIONS=$(echo "$PR_DETAIL" | jq -r '.deletions // 0' 2>/dev/null) || DELETIONS=0 + CHANGED_FILES=$(echo "$PR_DETAIL" | jq -r '.changedFiles // 0' 2>/dev/null) || CHANGED_FILES=0 + fi + + PR_STATE=$(echo "$PR" | jq -r '.state') + IS_DRAFT=$(echo "$PR" | jq -r '.isDraft') + MERGED_AT_RAW=$(echo "$PR" | jq -r '.mergedAt // empty') + + if [ -n "$MERGED_AT_RAW" ]; then + STATUS="Merged" + elif [ "$PR_STATE" = "CLOSED" ]; then + STATUS="Closed" + elif [ "$IS_DRAFT" = "true" ]; then + STATUS="Draft" + else + STATUS="Open" + fi + + CREATED_AT=$(fmt_date "$(echo "$PR" | jq -r '.createdAt // empty')") + UPDATED_AT=$(fmt_date "$(echo "$PR" | jq -r '.updatedAt // empty')") + CLOSED_AT=$(fmt_date "$(echo "$PR" | jq -r '.closedAt // empty')") + MERGED_AT_FMT=$(fmt_date "$(echo "$PR" | jq -r '.mergedAt // empty')") + + TITLE=$(echo "$PR" | jq -r '.title // empty') + AUTHOR=$(echo "$PR" | jq -r '.author.login // empty') + BRANCH=$(echo "$PR" | jq -r '.headRefName // empty') + BASE_BRANCH=$(echo "$PR" | jq -r '.baseRefName // empty') + HEAD_SHA=$(echo "$PR" | jq -r '.headRefOid // empty') + URL=$(echo "$PR" | jq -r '.url // empty') + BODY=$(echo "$PR" | jq -r '.body // empty' | head -c 5000) + LABELS=$(echo "$PR" | jq -r '[.labels[]?.name] | join(", ")') + MILESTONE=$(echo "$PR" | jq -r '.milestone.title // empty') + ASSIGNEES=$(echo "$PR" | jq -r '[.assignees[]?.login] | join(", ")') + REVIEWERS=$(echo "$PR" | jq -r '[.reviewRequests[]? | (.login // .name)] | join(", ")') + MERGED_BY=$(echo "$PR" | jq -r '.mergedBy.login // empty') + + if [ "$STATS_OK" = true ]; then + FIELDS=$(jq -n \ + --arg title "$TITLE" \ + --argjson pr_number "$PR_NUMBER" \ + --arg pr_index "[$PR_NUMBER]" \ + --arg author "$AUTHOR" \ + --arg repo "$REPO" \ + --arg status "$STATUS" \ + --arg url "$URL" \ + --arg branch "$BRANCH" \ + --arg base_branch "$BASE_BRANCH" \ + --arg head_sha "$HEAD_SHA" \ + --arg body "$BODY" \ + --arg created_at "$CREATED_AT" \ + --arg updated_at "$UPDATED_AT" \ + --arg closed_at "$CLOSED_AT" \ + --arg merged_at "$MERGED_AT_FMT" \ + --arg merged_by "$MERGED_BY" \ + --argjson commits "${COMMITS:-0}" \ + --argjson additions "${ADDITIONS:-0}" \ + --argjson deletions "${DELETIONS:-0}" \ + --argjson changed_files "${CHANGED_FILES:-0}" \ + --arg labels "$LABELS" \ + --arg milestone "$MILESTONE" \ + --arg assignees "$ASSIGNEES" \ + --arg reviewers "$REVIEWERS" \ + '{ + "PR标题": $title, "PR编号": $pr_number, "PR索引": $pr_index, + "作者": $author, "仓库": $repo, "状态": $status, "链接": $url, + "分支名称": $branch, "目标分支": $base_branch, "Head SHA": $head_sha, + "PR描述": $body, "创建时间": $created_at, "更新时间": $updated_at, + "提交数": $commits, "新增行数": $additions, "删除行数": $deletions, + "变更文件数": $changed_files, "标签": $labels, "里程碑": $milestone, + "指派人": $assignees, "审查人": $reviewers, "合并人": $merged_by + } + | if $closed_at != "" then . + {"关闭时间": $closed_at} else . end + | if $merged_at != "" then . + {"合并时间": $merged_at} else . end') + else + FIELDS=$(jq -n \ + --arg title "$TITLE" \ + --argjson pr_number "$PR_NUMBER" \ + --arg pr_index "[$PR_NUMBER]" \ + --arg author "$AUTHOR" \ + --arg repo "$REPO" \ + --arg status "$STATUS" \ + --arg url "$URL" \ + --arg branch "$BRANCH" \ + --arg base_branch "$BASE_BRANCH" \ + --arg head_sha "$HEAD_SHA" \ + --arg body "$BODY" \ + --arg created_at "$CREATED_AT" \ + --arg updated_at "$UPDATED_AT" \ + --arg closed_at "$CLOSED_AT" \ + --arg merged_at "$MERGED_AT_FMT" \ + --arg merged_by "$MERGED_BY" \ + --arg labels "$LABELS" \ + --arg milestone "$MILESTONE" \ + --arg assignees "$ASSIGNEES" \ + --arg reviewers "$REVIEWERS" \ + '{ + "PR标题": $title, "PR编号": $pr_number, "PR索引": $pr_index, + "作者": $author, "仓库": $repo, "状态": $status, "链接": $url, + "分支名称": $branch, "目标分支": $base_branch, "Head SHA": $head_sha, + "PR描述": $body, "创建时间": $created_at, "更新时间": $updated_at, + "标签": $labels, "里程碑": $milestone, + "指派人": $assignees, "审查人": $reviewers, "合并人": $merged_by + } + | if $closed_at != "" then . + {"关闭时间": $closed_at} else . end + | if $merged_at != "" then . + {"合并时间": $merged_at} else . end') + fi + + # --- Find existing record by PR索引 keyword --- + RECORD_ID="" + + SEARCH_RESULT=$(lark-cli base +record-search \ + --base-token "$BITABLE_APP_TOKEN" \ + --table-id "$BITABLE_TABLE_ID" \ + --as bot \ + --format json \ + --json "{\"keyword\":\"[$PR_NUMBER]\",\"search_fields\":[\"PR索引\"],\"select_fields\":[\"PR索引\"],\"limit\":200}" 2>&1) || true + + if echo "$SEARCH_RESULT" | jq -e '.ok == true' > /dev/null 2>&1; then + RECORD_ID=$(echo "$SEARCH_RESULT" | jq -r --arg idx "[$PR_NUMBER]" ' + .data as $d | + if ($d.record_id_list | length) == 0 then empty + elif ($d.fields | length) == 0 then $d.record_id_list[0] + else + ($d.fields | to_entries | map(select(.value == "PR索引")) | .[0].key // null) as $col | + if $col == null then $d.record_id_list[0] + else + [ $d.data | to_entries[] | select( + (.value[$col] | tostring) == $idx + ) | .key ] | .[0] as $row | + if $row == null then empty + else $d.record_id_list[$row | tonumber] + end + end + end // empty' 2>/dev/null) || true + fi + + # --- Upsert --- + ARGS=( + --base-token "$BITABLE_APP_TOKEN" + --table-id "$BITABLE_TABLE_ID" + --as bot + --json "$FIELDS" + ) + if [ -n "$RECORD_ID" ]; then + echo " Updating existing record: $RECORD_ID" + ARGS+=(--record-id "$RECORD_ID") + else + echo " Creating new record" + fi + if lark-cli base +record-upsert "${ARGS[@]}" > /dev/null 2>&1; then + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + else + echo " ::warning::upsert failed for PR #$PR_NUMBER, skipping" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + + sleep 1 + done < <(echo "$PRS" | jq -c '.[]') + + echo "=== Sync complete: $SUCCESS_COUNT succeeded, $FAIL_COUNT failed ===" + if [ "$FAIL_COUNT" -gt 0 ]; then + echo "::warning::$FAIL_COUNT PR(s) failed to sync" + fi diff --git a/.github/workflows/sync-pr-to-feishu.yml b/.github/workflows/sync-pr-to-feishu.yml new file mode 100644 index 00000000..2e4411fb --- /dev/null +++ b/.github/workflows/sync-pr-to-feishu.yml @@ -0,0 +1,227 @@ +name: Sync PR to Feishu Bitable + +on: + pull_request: + types: [opened, closed, reopened, edited, synchronize, converted_to_draft, ready_for_review] + pull_request_review: + types: [submitted] + +jobs: + sync-pr: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + concurrency: + group: feishu-pr-${{ github.event.pull_request.number }} + cancel-in-progress: false + env: + LARK_APP_ID: ${{ secrets.LARK_APP_ID }} + LARK_APP_SECRET: ${{ secrets.LARK_APP_SECRET }} + BITABLE_APP_TOKEN: ${{ secrets.BITABLE_APP_TOKEN }} + BITABLE_TABLE_ID: ${{ secrets.BITABLE_TABLE_ID }} + steps: + - name: Check secrets + run: | + if [ -z "$LARK_APP_SECRET" ]; then + echo "::error::LARK_APP_SECRET is not set, skipping." + exit 1 + fi + + - name: Install lark-cli + run: npm install -g @larksuite/cli + + - name: Configure lark-cli + run: | + echo "$LARK_APP_SECRET" | lark-cli config init --app-id "$LARK_APP_ID" --brand feishu --app-secret-stdin + lark-cli auth status + + - name: Determine PR Status + id: status + run: | + if [ "${{ github.event.pull_request.merged }}" = "true" ]; then + echo "pr_status=Merged" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event.action }}" = "closed" ]; then + echo "pr_status=Closed" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event.pull_request.draft }}" = "true" ]; then + echo "pr_status=Draft" >> "$GITHUB_OUTPUT" + else + echo "pr_status=Open" >> "$GITHUB_OUTPUT" + fi + + - name: Build Fields JSON + id: fields + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_REPO: ${{ github.event.repository.full_name }} + PR_STATUS: ${{ steps.status.outputs.pr_status }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_CREATED_AT: ${{ github.event.pull_request.created_at }} + PR_UPDATED_AT: ${{ github.event.pull_request.updated_at }} + PR_CLOSED_AT: ${{ github.event.pull_request.closed_at }} + PR_MERGED_AT: ${{ github.event.pull_request.merged_at }} + PR_MERGED_BY: ${{ github.event.pull_request.merged_by.login }} + PR_COMMITS: ${{ github.event.pull_request.commits }} + PR_ADDITIONS: ${{ github.event.pull_request.additions }} + PR_DELETIONS: ${{ github.event.pull_request.deletions }} + PR_CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ', ') }} + PR_MILESTONE: ${{ github.event.pull_request.milestone.title }} + PR_ASSIGNEES: ${{ join(github.event.pull_request.assignees.*.login, ', ') }} + PR_REVIEWERS: ${{ join(github.event.pull_request.requested_reviewers.*.login, ', ') }} + PR_REVIEW_TEAMS: ${{ join(github.event.pull_request.requested_teams.*.slug, ', ') }} + REVIEW_AUTHOR: ${{ github.event.review.user.login }} + EVENT_NAME: ${{ github.event_name }} + run: | + ALL_REVIEWERS="" + [ -n "$PR_REVIEWERS" ] && ALL_REVIEWERS="$PR_REVIEWERS" + [ -n "$PR_REVIEW_TEAMS" ] && ALL_REVIEWERS="${ALL_REVIEWERS:+$ALL_REVIEWERS, }$PR_REVIEW_TEAMS" + [ -n "$REVIEW_AUTHOR" ] && ALL_REVIEWERS="${ALL_REVIEWERS:+$ALL_REVIEWERS, }$REVIEW_AUTHOR" + REVIEWERS=$(echo "$ALL_REVIEWERS" | tr ',' '\n' | sed 's/^ *//' | grep -v '^$' | sort -u | paste -sd ', ') || true + + fmt_date() { + local raw="$1" + [ -z "$raw" ] || [ "$raw" = "null" ] && echo "" && return + date -d "$raw" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$raw" + } + CREATED_AT=$(fmt_date "$PR_CREATED_AT") + UPDATED_AT=$(fmt_date "$PR_UPDATED_AT") + CLOSED_AT=$(fmt_date "$PR_CLOSED_AT") + MERGED_AT=$(fmt_date "$PR_MERGED_AT") + + BODY_TRUNCATED=$(printf '%s' "$PR_BODY" | head -c 5000) + + STATS_ARGS="" + if [ "$EVENT_NAME" = "pull_request" ]; then + STATS_ARGS=$(jq -n \ + --argjson commits "${PR_COMMITS:-0}" \ + --argjson additions "${PR_ADDITIONS:-0}" \ + --argjson deletions "${PR_DELETIONS:-0}" \ + --argjson changed_files "${PR_CHANGED_FILES:-0}" \ + '{"提交数": $commits, "新增行数": $additions, "删除行数": $deletions, "变更文件数": $changed_files}') + fi + + BASE_FIELDS=$(jq -n \ + --arg title "$PR_TITLE" \ + --arg author "$PR_AUTHOR" \ + --arg repo "$PR_REPO" \ + --arg status "$PR_STATUS" \ + --arg url "$PR_URL" \ + --arg branch "$PR_BRANCH" \ + --arg base_branch "$PR_BASE_BRANCH" \ + --arg head_sha "$PR_HEAD_SHA" \ + --arg body "$BODY_TRUNCATED" \ + --arg created_at "$CREATED_AT" \ + --arg updated_at "$UPDATED_AT" \ + --arg closed_at "$CLOSED_AT" \ + --arg merged_at "$MERGED_AT" \ + --arg merged_by "${PR_MERGED_BY:-}" \ + --arg labels "$PR_LABELS" \ + --arg milestone "${PR_MILESTONE:-}" \ + --arg assignees "$PR_ASSIGNEES" \ + --arg reviewers "$REVIEWERS" \ + --argjson pr_number "$PR_NUMBER" \ + --arg pr_index "[$PR_NUMBER]" \ + '{ + "PR标题": $title, + "PR编号": $pr_number, + "PR索引": $pr_index, + "作者": $author, + "仓库": $repo, + "状态": $status, + "链接": $url, + "分支名称": $branch, + "目标分支": $base_branch, + "Head SHA": $head_sha, + "PR描述": $body, + "创建时间": $created_at, + "更新时间": $updated_at, + "标签": $labels, + "里程碑": $milestone, + "指派人": $assignees, + "审查人": $reviewers, + "合并人": $merged_by + } + | if $closed_at != "" then . + {"关闭时间": $closed_at} else . end + | if $merged_at != "" then . + {"合并时间": $merged_at} else . end') + + if [ -n "$STATS_ARGS" ]; then + FIELDS=$(echo "$BASE_FIELDS" "$STATS_ARGS" | jq -s '.[0] * .[1]') + else + FIELDS="$BASE_FIELDS" + fi + + echo "Fields: $FIELDS" + echo "$FIELDS" > /tmp/fields.json + + - name: Find Existing Record + id: find + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + RECORD_ID="" + + RESULT=$(lark-cli base +record-search \ + --base-token "$BITABLE_APP_TOKEN" \ + --table-id "$BITABLE_TABLE_ID" \ + --as bot \ + --format json \ + --json "{\"keyword\":\"[$PR_NUMBER]\",\"search_fields\":[\"PR索引\"],\"select_fields\":[\"PR索引\"],\"limit\":200}" 2>&1) || true + + echo "Search raw result: $RESULT" + + if echo "$RESULT" | jq -e '.ok == true' > /dev/null 2>&1; then + RECORD_ID=$(echo "$RESULT" | jq -r --arg idx "[$PR_NUMBER]" ' + .data as $d | + if ($d.record_id_list | length) == 0 then empty + elif ($d.fields | length) == 0 then $d.record_id_list[0] + else + ($d.fields | to_entries | map(select(.value == "PR索引")) | .[0].key // null) as $col | + if $col == null then $d.record_id_list[0] + else + [ $d.data | to_entries[] | select( + (.value[$col] | tostring) == $idx + ) | .key ] | .[0] as $row | + if $row == null then empty + else $d.record_id_list[$row | tonumber] + end + end + end // empty' 2>/dev/null) || true + fi + + echo "Found record_id: ${RECORD_ID:-}" + echo "record_id=$RECORD_ID" >> "$GITHUB_OUTPUT" + + - name: Sync to Feishu Bitable + env: + RECORD_ID: ${{ steps.find.outputs.record_id }} + run: | + FIELDS=$(cat /tmp/fields.json) + echo "Writing to Bitable: $FIELDS" + ARGS=( + --base-token "$BITABLE_APP_TOKEN" + --table-id "$BITABLE_TABLE_ID" + --as bot + --json "$FIELDS" + ) + if [ -n "$RECORD_ID" ]; then + echo "Updating existing record: $RECORD_ID" + ARGS+=(--record-id "$RECORD_ID") + else + echo "Creating new record" + fi + + for attempt in 1 2 3; do + if lark-cli base +record-upsert "${ARGS[@]}"; then + echo "Upsert succeeded" + exit 0 + fi + echo "::warning::Upsert attempt $attempt failed, retrying..." + sleep 2 + done + echo "::error::Upsert failed after 3 attempts" + exit 1 diff --git a/SECURITY_REPORT.md b/SECURITY_REPORT.md new file mode 100644 index 00000000..2664d109 --- /dev/null +++ b/SECURITY_REPORT.md @@ -0,0 +1,208 @@ +# PilotDeck 安全漏洞报告 + +**报告日期:** 2026-05-28 +**影响版本:** 当前 main 分支(所有版本) +**严重程度:** 🔴 严重(Critical) + +--- + +## 一、漏洞概述 + +PilotDeck 存在两个设计缺陷,组合后导致同一局域网下的任何设备无需任何身份认证即可完全控制用户电脑。 + +| 缺陷 | 位置 | 说明 | +|------|------|------| +| 默认监听 `0.0.0.0` | `ui/server/index.js:2943` 等 3 处 | 服务暴露在所有网络接口,局域网可直接访问 | +| 默认关闭认证 | `ui/server/constants/config.js:15` | `DISABLE_LOCAL_AUTH` 默认为 `true`,跳过全部 JWT 验证 | + +**一句话总结:** 用户启动 PilotDeck 后,同一 WiFi 下的任何人在浏览器输入 `http://<用户IP>:3001` 即可无密码访问全部 API,包括读写文件、窃取密钥、执行系统命令。 + +--- + +## 二、影响范围 + +### 谁会受影响 + +- 所有使用默认配置启动 PilotDeck 的用户 +- 尤其是在公司办公网络、咖啡厅、酒店等共享 WiFi 环境下的用户 + +### 攻击者能做什么 + +| 危害等级 | 攻击行为 | 对应 API | +|---------|---------|---------| +| 🔴 远程代码执行 | 通过 WebSocket 获得完整系统终端 | `/shell` WebSocket | +| 🔴 文件读取 | 读取用户电脑上任意文件的完整内容 | `GET /api/projects/:name/file` | +| 🔴 文件写入/删除 | 覆写或递归删除用户文件 | `PUT /api/.../file`, `DELETE /api/.../files` | +| 🔴 密钥窃取 | 获取 AI Provider API Key 明文 | `GET /api/config/provider` | +| 🔴 项目打包下载 | 将整个项目目录打包为 ZIP 下载 | `GET /api/projects/:name/download` | +| 🟠 配置篡改 | 修改服务配置、权限设置 | `PUT /api/config`, `PUT /api/settings/permissions` | +| 🟠 聊天记录泄露 | 读取用户所有对话历史 | `GET /api/projects/:name/sessions` | +| 🟠 Git 仓库操控 | 提交、推送、删除分支 | `POST /api/git/push` 等 | +| 🟡 SSRF | 利用服务器向内网发起请求 | `POST /api/config/test-connection` | + +--- + +## 三、复现步骤 + +### 环境 + +- 攻击者和受害者连接在同一局域网(如同一 WiFi) +- 受害者以默认配置启动 PilotDeck + +### 步骤 1:确认服务暴露 + +攻击者在自己设备的浏览器中输入: + +``` +http://<受害者IP>:3001 +``` + +无需登录,直接进入 PilotDeck Web UI。 + +### 步骤 2:窃取 API Key + +```bash +curl http://<受害者IP>:3001/api/config/provider +``` + +返回结果(明文 API Key): + +```json +{ + "exists": true, + "provider": { + "type": "openai", + "baseUrl": "https://api.deepseek.com/v1", + "apiKey": "sk-xxxxxxxxxxxxxxxxxxxx", + "model": "deepseek-v4-pro" + } +} +``` + +### 步骤 3:读取任意文件内容 + +```bash +curl "http://<受害者IP>:3001/api/projects/<项目名>/file?filePath=pilotdeck.yaml" +``` + +返回文件完整内容。 + +### 步骤 4:获取远程终端 + +```javascript +const ws = new WebSocket("ws://<受害者IP>:3001/shell"); +ws.onopen = () => ws.send(JSON.stringify({ + type: "init", + projectPath: "/tmp", + initialCommand: "whoami && cat /etc/passwd", + isPlainShell: true +})); +ws.onmessage = (e) => console.log(e.data); +``` + +攻击者获得受害者系统的完整 shell 权限。 + +--- + +## 四、根本原因分析 + +### 缺陷 1:监听地址默认为 `0.0.0.0` + +以下三处硬编码了 `0.0.0.0`: + +``` +ui/server/index.js:2943 + const HOST = process.env.HOST || '0.0.0.0'; + +ui/server/services/pilotdeckConfig.js:85 + host: '0.0.0.0', + +ui/server/services/pilotdeckConfig.js:316 + HOST: process.env.HOST || String(runtime.host ?? '0.0.0.0'), + +ui/server/cli.js:191 + const host = process.env.HOST || '0.0.0.0'; +``` + +`0.0.0.0` 意味着接受来自所有网络接口的连接。对于本地桌面工具,应默认仅监听 `127.0.0.1`。 + +### 缺陷 2:认证默认关闭 + +```javascript +// ui/server/constants/config.js:15-17 +export const DISABLE_LOCAL_AUTH = + process.env.PILOTDECK_DISABLE_LOCAL_AUTH !== '0' && + process.env.PILOTDECK_DISABLE_LOCAL_AUTH !== 'false'; +``` + +当环境变量 `PILOTDECK_DISABLE_LOCAL_AUTH` 未设置时,`undefined !== '0'` 为 `true`,导致认证被完全跳过。`authenticateToken` 中间件直接返回数据库中第一个用户,所有受保护的 API 变为完全开放。 + +### 两个缺陷的叠加效应 + +单独来看各有一定合理性(方便容器部署 / 降低使用门槛),但组合起来 = **全网络暴露 + 无认证 = 局域网内任何人完全控制用户电脑**。 + +--- + +## 五、建议修复方案 + +### 修复 1:默认监听地址改为 `127.0.0.1`(优先级:P0) + +```diff +# ui/server/index.js:2943 +- const HOST = process.env.HOST || '0.0.0.0'; ++ const HOST = process.env.HOST || '127.0.0.1'; + +# ui/server/services/pilotdeckConfig.js:85 +- host: '0.0.0.0', ++ host: '127.0.0.1', + +# ui/server/services/pilotdeckConfig.js:316 +- HOST: process.env.HOST || String(runtime.host ?? '0.0.0.0'), ++ HOST: process.env.HOST || String(runtime.host ?? '127.0.0.1'), + +# ui/server/cli.js:191 +- const host = process.env.HOST || '0.0.0.0'; ++ const host = process.env.HOST || '127.0.0.1'; +``` + +### 修复 2:认证默认改为开启(优先级:P0) + +```diff +# ui/server/constants/config.js:15-17 +- export const DISABLE_LOCAL_AUTH = +- process.env.PILOTDECK_DISABLE_LOCAL_AUTH !== '0' && +- process.env.PILOTDECK_DISABLE_LOCAL_AUTH !== 'false'; ++ export const DISABLE_LOCAL_AUTH = ++ process.env.PILOTDECK_DISABLE_LOCAL_AUTH === '1' || ++ process.env.PILOTDECK_DISABLE_LOCAL_AUTH === 'true'; +``` + +### 修复 3:其他关联安全问题(优先级:P1) + +| 问题 | 文件 | 建议 | +|------|------|------| +| `/api/config/provider` 返回明文 API Key | `routes/config.js:163` | 对 `apiKey` 字段做脱敏处理 | +| GitHub token 嵌入 clone URL 泄露到日志 | `routes/projects.js:548` | 使用 `GIT_ASKPASS` 或 header auth | +| API Key 可通过 query param 传递 | `routes/agent.js:44` | 仅允许 header 传递 | +| `_safeFilePath` 路径遍历防护不完整 | `sessionManager.js:141` | 改用递归替换或白名单校验 | +| `validateFilePath` 缺少 projectPath 参数 | `routes/git.js:251` | 传入 projectPath 启用路径遍历检查 | +| `/load` 路由路径检查可绕过 | `routes/commands.js:884` | 改用 `path.relative` 前缀检查 | +| SQLite 事务内使用 await | `routes/auth.js:48` | 将 bcrypt.hash 移到事务外部 | + +--- + +## 六、临时缓解措施(用户可立即执行) + +在官方修复发布前,用户可通过以下方式自我保护: + +```bash +# 方法 1:设置环境变量启动 +HOST=127.0.0.1 PILOTDECK_DISABLE_LOCAL_AUTH=0 pilotdeck + +# 方法 2:开启 macOS 防火墙 +# 系统偏好设置 → 网络 → 防火墙 → 打开 +``` + +--- + +*本报告由安全审计生成,建议以 P0 优先级修复缺陷 1 和缺陷 2。* diff --git a/bin/pilotdeck b/bin/pilotdeck new file mode 100755 index 00000000..d5c9185b --- /dev/null +++ b/bin/pilotdeck @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE="${BASH_SOURCE[0]}" +while [[ -L "$SOURCE" ]]; do + SOURCE_DIR="$(cd "$(dirname "$SOURCE")" && pwd)" + LINK_TARGET="$(readlink "$SOURCE")" + if [[ "$LINK_TARGET" == /* ]]; then + SOURCE="$LINK_TARGET" + else + SOURCE="$SOURCE_DIR/$LINK_TARGET" + fi +done +INSTALL_DIR="$(cd "$(dirname "$SOURCE")/.." && pwd)" +CONFIG_FILE="${PILOTDECK_CONFIG_PATH:-$HOME/.pilotdeck/pilotdeck.yaml}" +MAX_PORT_TRIES="${PILOTDECK_MAX_PORT_TRIES:-20}" + +fail() { printf "pilotdeck: %s\n" "$1" >&2; exit 1; } +warn() { printf "pilotdeck: %s\n" "$1" >&2; } + +is_port_free() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + elif command -v ss >/dev/null 2>&1; then + ! ss -tlnH "sport = :$port" 2>/dev/null | grep -q . + else + ! (echo >/dev/tcp/127.0.0.1/"$port") 2>/dev/null + fi +} + +find_free_port() { + local base="$1" + local offset candidate + for ((offset = 0; offset < MAX_PORT_TRIES; offset++)); do + candidate=$((base + offset)) + if is_port_free "$candidate"; then + printf "%s" "$candidate" + return 0 + fi + done + return 1 +} + +git_remote_url() { + git -C "$INSTALL_DIR" remote get-url origin 2>/dev/null || printf "unknown" +} + +git_branch_name() { + git -C "$INSTALL_DIR" branch --show-current 2>/dev/null || printf "unknown" +} + +COMMAND="start" +while [[ $# -gt 0 ]]; do + case "$1" in + start) + COMMAND="start" + shift + ;; + status|info) + COMMAND="status" + shift + ;; + help|-h|--help) + COMMAND="help" + shift + ;; + --port|-p) + [[ $# -ge 2 ]] || fail "--port requires a value" + SERVER_PORT="$2" + shift 2 + ;; + --port=*) + SERVER_PORT="${1#--port=}" + shift + ;; + --config) + [[ $# -ge 2 ]] || fail "--config requires a value" + CONFIG_FILE="$2" + shift 2 + ;; + --config=*) + CONFIG_FILE="${1#--config=}" + shift + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +if [[ "$COMMAND" == "help" ]]; then + cat <] [--config ] + pilotdeck status + pilotdeck help + +HELP + exit 0 +fi + +if [[ "$COMMAND" == "status" ]]; then + SERVER_BASE="${SERVER_PORT:-3001}" + NEXT_SERVER_PORT="$(find_free_port "$SERVER_BASE" || printf "%s" "$SERVER_BASE")" + printf "Installation: %s\n" "$INSTALL_DIR" + printf "Remote: %s\n" "$(git_remote_url)" + printf "Branch: %s\n" "$(git_branch_name)" + printf "Config: %s\n" "$CONFIG_FILE" + printf "Default URL: http://localhost:%s\n" "$SERVER_BASE" + printf "Next start: http://localhost:%s\n" "$NEXT_SERVER_PORT" + exit 0 +fi + +SERVER_BASE="${SERVER_PORT:-3001}" +GATEWAY_BASE="${PILOTDECK_GATEWAY_PORT:-18789}" +SERVER_PORT="$(find_free_port "$SERVER_BASE")" || fail "could not find a free UI port from ${SERVER_BASE}" +PILOTDECK_GATEWAY_PORT="$(find_free_port "$GATEWAY_BASE")" || fail "could not find a free gateway port from ${GATEWAY_BASE}" +PILOTDECK_GATEWAY_URL="ws://127.0.0.1:${PILOTDECK_GATEWAY_PORT}/ws" + +export PILOTDECK_CONFIG_PATH="$CONFIG_FILE" +export SERVER_PORT PILOTDECK_GATEWAY_PORT PILOTDECK_GATEWAY_URL + +if [[ "$SERVER_PORT" != "$SERVER_BASE" ]]; then + warn "UI port ${SERVER_BASE} is busy; using ${SERVER_PORT} instead." +fi +if [[ "$PILOTDECK_GATEWAY_PORT" != "$GATEWAY_BASE" ]]; then + warn "Gateway port ${GATEWAY_BASE} is busy; using ${PILOTDECK_GATEWAY_PORT} instead." +fi + +node "$INSTALL_DIR/scripts/bootstrap-pilotdeck-config.mjs" + +printf "pilotdeck: starting at http://localhost:%s\n" "$SERVER_PORT" +export PILOTDECK_SKIP_DEFAULT_PROJECT=1 +cd "$INSTALL_DIR/ui" +exec npm run start:built diff --git a/snake-game-preview.png b/snake-game-preview.png new file mode 100644 index 00000000..e2ed1dce Binary files /dev/null and b/snake-game-preview.png differ diff --git a/snake-snapshot.yml b/snake-snapshot.yml new file mode 100644 index 00000000..e69de29b diff --git a/ui/server/cli.js b/ui/server/cli.js index 21610281..172f6563 100755 --- a/ui/server/cli.js +++ b/ui/server/cli.js @@ -188,7 +188,7 @@ function ensureFrontendBuild() { } async function startServer() { - const host = process.env.HOST || '0.0.0.0'; + const host = process.env.HOST || '127.0.0.1'; const port = process.env.SERVER_PORT || '3001'; await assertPortAvailable(port, host); ensureFrontendBuild(); diff --git a/ui/server/constants/config.js b/ui/server/constants/config.js index 37570f6f..5e42da74 100644 --- a/ui/server/constants/config.js +++ b/ui/server/constants/config.js @@ -9,9 +9,9 @@ export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true'; /** * When true, skip JWT login/register in the web UI (single-user local mode). - * Set PILOTDECK_DISABLE_LOCAL_AUTH=0 or false to require username/password again. + * Set PILOTDECK_DISABLE_LOCAL_AUTH=1 or true to disable authentication. * @type {boolean} */ export const DISABLE_LOCAL_AUTH = - process.env.PILOTDECK_DISABLE_LOCAL_AUTH !== '0' && - process.env.PILOTDECK_DISABLE_LOCAL_AUTH !== 'false'; + process.env.PILOTDECK_DISABLE_LOCAL_AUTH === '1' || + process.env.PILOTDECK_DISABLE_LOCAL_AUTH === 'true'; diff --git a/ui/server/index.js b/ui/server/index.js index 5cd8408a..b0228f45 100755 --- a/ui/server/index.js +++ b/ui/server/index.js @@ -2937,7 +2937,7 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = } const SERVER_PORT = process.env.SERVER_PORT || 3001; -const HOST = process.env.HOST || '0.0.0.0'; +const HOST = process.env.HOST || '127.0.0.1'; const DISPLAY_HOST = getConnectableHost(HOST); const VITE_PORT = process.env.VITE_PORT || 5173; diff --git a/ui/server/services/pilotdeckConfig.js b/ui/server/services/pilotdeckConfig.js index 015cae01..2a864f87 100644 --- a/ui/server/services/pilotdeckConfig.js +++ b/ui/server/services/pilotdeckConfig.js @@ -82,7 +82,7 @@ export function buildDefaultPilotDeckConfig() { }, webui: { runtime: { - host: '0.0.0.0', + host: '127.0.0.1', serverPort: 3001, vitePort: 5173, proxyPort: 18080, @@ -313,7 +313,7 @@ export function buildRuntimeEnv(config) { PROXY_PORT: process.env.PROXY_PORT || proxyPort, SERVER_PORT: process.env.SERVER_PORT || String(runtime.serverPort ?? 3001), VITE_PORT: process.env.VITE_PORT || String(runtime.vitePort ?? 5173), - HOST: process.env.HOST || String(runtime.host ?? '0.0.0.0'), + HOST: process.env.HOST || String(runtime.host ?? '127.0.0.1'), API_TIMEOUT_MS: String(runtime.apiTimeoutMs ?? 120000), PILOTDECK_MEMORY_ENABLED: normalized.memory?.enabled ? '1' : '0', };