@xiaobuyu/nodesign 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -106
- package/bin/nodesign.js +0 -0
- package/package.json +1 -1
- package/server/api/README.md +45 -45
- package/server/api/_guard.js +75 -75
- package/server/api/admin.js +187 -187
- package/server/api/assets/docx-page.js +103 -103
- package/server/api/assets/helpers.js +42 -22
- package/server/api/assets/notes.js +194 -144
- package/server/api/assets.js +891 -896
- package/server/api/board.js +134 -57
- package/server/api/browse.js +138 -138
- package/server/api/canvas.js +444 -444
- package/server/api/chatai.js +324 -324
- package/server/api/exports/build-standalone.js +980 -980
- package/server/api/exports/cards.js +118 -118
- package/server/api/exports/docx-pdf.js +46 -46
- package/server/api/exports/export-page.js +142 -142
- package/server/api/exports/handoff.js +147 -147
- package/server/api/exports.js +690 -690
- package/server/api/instruction.js +75 -75
- package/server/api/me.js +116 -116
- package/server/api/memory.js +185 -185
- package/server/api/pending-changes.js +366 -366
- package/server/api/plugins.js +164 -164
- package/server/api/projects.js +220 -220
- package/server/api/publish.js +64 -64
- package/server/api/recent.js +73 -73
- package/server/api/sessions.js +589 -589
- package/server/api/skills.js +77 -77
- package/server/api/standalone-fit.js +168 -168
- package/server/api/turn-compose.js +180 -180
- package/server/api/turn-inflight.js +44 -44
- package/server/auth/README.md +17 -17
- package/server/auth/middleware.js +138 -138
- package/server/auth/origin-guard.js +71 -71
- package/server/auth/session.js +108 -108
- package/server/auth/tier.js +113 -113
- package/server/auth/users-store.js +348 -348
- package/server/edit/README.md +29 -29
- package/server/engine/README.md +113 -113
- package/server/engine/agent/agent-shared.js +550 -550
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
- package/server/engine/agent/auto-mode-rules.js +78 -78
- package/server/engine/agent/context.js +418 -418
- package/server/engine/agent/events.js +465 -464
- package/server/engine/agent/hooks/canvas-validate.js +199 -199
- package/server/engine/agent/hooks/failure.js +120 -120
- package/server/engine/agent/hooks/file-events.js +75 -75
- package/server/engine/agent/hooks/lifecycle.js +233 -233
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
- package/server/engine/agent/hooks/post-guidance.js +206 -206
- package/server/engine/agent/hooks/post-subagent-report.js +104 -104
- package/server/engine/agent/hooks/post-trim.js +58 -58
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
- package/server/engine/agent/hooks/pre-defaults.js +84 -84
- package/server/engine/agent/hooks/pre-injectors.js +277 -277
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
- package/server/engine/agent/hooks/pre-starter-files.js +61 -61
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
- package/server/engine/agent/hooks/site-validate.js +94 -94
- package/server/engine/agent/hooks/tool-prompts.js +31 -31
- package/server/engine/agent/hooks/turn-state-memory.js +56 -56
- package/server/engine/agent/hooks/user-prompt-submit.js +258 -215
- package/server/engine/agent/hooks.js +354 -354
- package/server/engine/agent/init-contract.js +69 -69
- package/server/engine/agent/isolation.js +159 -159
- package/server/engine/agent/model-table.js +292 -292
- package/server/engine/agent/plugin-loader.js +266 -266
- package/server/engine/agent/prompts/nodesign-prelude.md +388 -336
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
- package/server/engine/agent/prompts/tools/site-reference.md +106 -106
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
- package/server/engine/agent/session-loop.js +1035 -1024
- package/server/engine/agent/session-model.js +164 -164
- package/server/engine/agent/skill.js +204 -204
- package/server/engine/agent/task-events.js +78 -78
- package/server/engine/agents/ds-extractor.md +132 -132
- package/server/engine/agents/explorer.md +190 -190
- package/server/engine/agents/index.js +230 -230
- package/server/engine/agents/schemas/design-system.json +156 -156
- package/server/engine/agents/schemas/tweak-schema.json +113 -113
- package/server/engine/agents/tweak-proposer.md +127 -127
- package/server/engine/agents/vision-checker.md +254 -254
- package/server/engine/browse/capture.js +422 -422
- package/server/engine/browse/card.js +137 -137
- package/server/engine/browse/handover.js +59 -59
- package/server/engine/browse/page-digest.js +119 -119
- package/server/engine/browse/refs.js +196 -196
- package/server/engine/browse/registry.js +307 -307
- package/server/engine/browse/screencast.js +170 -170
- package/server/engine/browse/state.js +121 -121
- package/server/engine/chatai/chat-log.js +79 -79
- package/server/engine/chatai/index.js +193 -193
- package/server/engine/chatai/openai-compat.js +152 -152
- package/server/engine/chatai/orchestrate.js +309 -309
- package/server/engine/chatai/perform.js +40 -40
- package/server/engine/chatai/summarize.js +79 -79
- package/server/engine/mcp/capability-gate.js +52 -52
- package/server/engine/mcp/index.js +351 -339
- package/server/engine/mcp/param-sanitizer.js +142 -142
- package/server/engine/mcp/tools/arrange-on-board.js +124 -124
- package/server/engine/mcp/tools/artifact-session.js +278 -278
- package/server/engine/mcp/tools/browse-computer.js +421 -421
- package/server/engine/mcp/tools/browse-find-batch.js +183 -183
- package/server/engine/mcp/tools/browse-screenshot.js +157 -157
- package/server/engine/mcp/tools/browse.js +480 -480
- package/server/engine/mcp/tools/build-docx.js +101 -101
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
- package/server/engine/mcp/tools/create-on-board.js +152 -155
- package/server/engine/mcp/tools/crystallize-skill.js +168 -168
- package/server/engine/mcp/tools/edit-sketch.js +167 -0
- package/server/engine/mcp/tools/explain-style.js +237 -237
- package/server/engine/mcp/tools/export-handoff.js +133 -133
- package/server/engine/mcp/tools/expose-tweaks.js +149 -149
- package/server/engine/mcp/tools/generate-image.js +842 -842
- package/server/engine/mcp/tools/get-computed-styles.js +164 -164
- package/server/engine/mcp/tools/get-pending-changes.js +205 -205
- package/server/engine/mcp/tools/h3box-ssh.js +84 -84
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
- package/server/engine/mcp/tools/highlight.js +68 -68
- package/server/engine/mcp/tools/list-pages.js +222 -222
- package/server/engine/mcp/tools/look-at-board.js +124 -0
- package/server/engine/mcp/tools/lookup-tags.js +59 -59
- package/server/engine/mcp/tools/navigate-to-page.js +60 -60
- package/server/engine/mcp/tools/organize-board.js +64 -64
- package/server/engine/mcp/tools/paint-still.js +434 -434
- package/server/engine/mcp/tools/pin-to-board.js +130 -130
- package/server/engine/mcp/tools/preview-deck.js +130 -130
- package/server/engine/mcp/tools/profile-scroll.js +305 -305
- package/server/engine/mcp/tools/publish-site.js +102 -102
- package/server/engine/mcp/tools/query-elements.js +195 -195
- package/server/engine/mcp/tools/read-board.js +190 -103
- package/server/engine/mcp/tools/read-document.js +93 -93
- package/server/engine/mcp/tools/read-page.js +264 -264
- package/server/engine/mcp/tools/read-tavern-json.js +143 -143
- package/server/engine/mcp/tools/read-user-view.js +59 -0
- package/server/engine/mcp/tools/record-decision.js +140 -140
- package/server/engine/mcp/tools/relate-on-board.js +112 -110
- package/server/engine/mcp/tools/report-issue.js +115 -115
- package/server/engine/mcp/tools/roll-film.js +266 -266
- package/server/engine/mcp/tools/screenshot-docx.js +138 -138
- package/server/engine/mcp/tools/screenshot-url.js +195 -195
- package/server/engine/mcp/tools/screenshot.js +583 -583
- package/server/engine/mcp/tools/sketch-on-board.js +337 -0
- package/server/engine/mcp/tools/tier-gate.js +96 -96
- package/server/engine/mcp/tools/trace-motion.js +215 -215
- package/server/engine/mcp/tools/web-search.js +548 -548
- package/server/engine/mcp/tools/write-on-board.js +130 -0
- package/server/engine/motion/inventory.js +349 -349
- package/server/engine/perception/session.js +235 -235
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +199 -192
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +169 -162
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +235 -228
- package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +330 -323
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
- package/server/engine/runs/active-runs.js +698 -698
- package/server/engine/runs/board-tasklist.js +130 -0
- package/server/engine/runs/live-turn.js +252 -252
- package/server/engine/runs/store.js +364 -364
- package/server/engine/runs/turn-relay.js +217 -217
- package/server/engine/runtime/workspace.js +164 -164
- package/server/index.js +3 -0
- package/server/lib/artifact-file-path.js +80 -80
- package/server/lib/artifact-target.js +284 -284
- package/server/lib/asset-refs.js +239 -239
- package/server/lib/async-queue.js +104 -104
- package/server/lib/auto-relations.js +130 -130
- package/server/lib/binding-types.js +72 -60
- package/server/lib/board-graph-export.js +191 -0
- package/server/lib/board-groups.js +167 -0
- package/server/lib/board-hero.js +76 -0
- package/server/lib/board-kind-sizes.js +85 -62
- package/server/lib/board-relations.js +133 -133
- package/server/lib/browse-proxy.js +180 -180
- package/server/lib/canvas-id.js +36 -36
- package/server/lib/chalk.js +121 -0
- package/server/lib/cover.js +227 -227
- package/server/lib/danbooru-tags.js +291 -291
- package/server/lib/doc-extract.js +156 -156
- package/server/lib/docx/build-from-source.js +260 -260
- package/server/lib/docx/build.js +490 -490
- package/server/lib/docx/dump-styles.js +287 -287
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
- package/server/lib/docx/merge-runs.js +129 -129
- package/server/lib/docx/numbering.js +218 -218
- package/server/lib/docx/order.js +152 -152
- package/server/lib/docx/rawzip.js +172 -172
- package/server/lib/docx/render.js +92 -92
- package/server/lib/docx/text-lint.js +225 -225
- package/server/lib/docx/tokens.js +335 -335
- package/server/lib/docx/units.js +29 -29
- package/server/lib/docx/xml.js +291 -291
- package/server/lib/docx-pages.js +187 -187
- package/server/lib/export-collect.js +289 -289
- package/server/lib/export-package.js +210 -210
- package/server/lib/html-srcset.js +145 -145
- package/server/lib/image-variant.js +460 -460
- package/server/lib/ingress/forward-openai-chat.js +305 -305
- package/server/lib/ingress/openai-chat.js +491 -491
- package/server/lib/ingress/session-notice.js +66 -66
- package/server/lib/ingress/session-routes.js +68 -68
- package/server/lib/ingress/slot-probe.js +130 -130
- package/server/lib/ingress/upstream-billing.js +52 -52
- package/server/lib/ingress/upstream-fail-streak.js +72 -72
- package/server/lib/ingress/upstream-truncation.js +48 -48
- package/server/lib/issues-store.js +182 -182
- package/server/lib/kinds/deck.js +72 -72
- package/server/lib/kinds/docx.js +283 -283
- package/server/lib/kinds/file-kinds.js +113 -113
- package/server/lib/kinds/index.js +202 -202
- package/server/lib/kinds/site.js +161 -161
- package/server/lib/model-ingress.js +543 -543
- package/server/lib/moderation.js +255 -255
- package/server/lib/notice-store.js +103 -103
- package/server/lib/plugin-install.js +104 -104
- package/server/lib/plugin-validator.js +721 -721
- package/server/lib/publish-store.js +115 -115
- package/server/lib/quick-summary.js +52 -52
- package/server/lib/quota.js +270 -270
- package/server/lib/rate-window.js +29 -29
- package/server/lib/reference-assets.js +92 -92
- package/server/lib/region-shot.js +135 -135
- package/server/lib/safe-path.js +73 -73
- package/server/lib/sdk-session.js +33 -33
- package/server/lib/showcase-store.js +92 -92
- package/server/lib/site-publish.js +465 -465
- package/server/lib/sketch-layout.js +277 -0
- package/server/lib/ssrf-guard.js +432 -432
- package/server/lib/task-scan.js +158 -158
- package/server/lib/tavern-json.js +154 -154
- package/server/lib/video-variant.js +237 -237
- package/server/lib/workspace-path.js +33 -33
- package/server/ops/fix-sdk-musl.mjs +40 -40
- package/server/ops/install-macos-fonts.sh +114 -114
- package/server/ops/macos-fonts.conf +336 -336
- package/server/ops/sandbox-shim/bwrap +57 -57
- package/server/projects/assets-summary.js +119 -119
- package/server/projects/auto-name.js +53 -53
- package/server/projects/board-limits.js +12 -0
- package/server/projects/board-sanitize.js +252 -0
- package/server/projects/board-store.js +507 -640
- package/server/projects/move-entry.js +103 -103
- package/server/projects/store.js +270 -270
- package/server/projects/ui-config.js +60 -54
- package/server/projects/viewpoint-store.js +78 -0
- package/server/projects/workspace-templates.js +53 -53
- package/server/projects/workspace.js +1025 -1025
- package/server/runtime/local-config.js +177 -177
- package/server/runtime/profile.js +72 -72
- package/server/services/rembg-service.py +334 -334
- package/server/shared/README.md +25 -25
- package/server/shared/deck.js +54 -54
- package/server/shared/time.js +49 -49
- package/server/style-pipeline/README.md +53 -53
- package/server/ws/broker.js +39 -36
- package/server/ws/browse-channel.js +177 -177
- package/server/ws/index.js +386 -386
- package/web/README.md +89 -89
- package/web/dist/assets/BrowserWindow-P_vIdh-G.js +11 -0
- package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-BpLv5082.js} +3 -3
- package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-DSAU1-tD.js} +4 -4
- package/web/dist/assets/MermaidBlock-DR1GikCv.js +313 -0
- package/web/dist/assets/SiteWindow-92uBuRRR.js +18 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-uA8Esix4.js +1 -0
- package/web/dist/assets/arc-D_khH_xN.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-DydJHqOt.js +36 -0
- package/web/dist/assets/blockDiagram-NRAW4CY4-B7wBbKiW.js +129 -0
- package/web/dist/assets/c4Diagram-UCG6FXSJ-6mrH0x7Y.js +38 -0
- package/web/dist/assets/channel-DGkccMO1.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-Cuikho5Q.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-3-KZ5AUz.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-Bkwq2gGU.js +1 -0
- package/web/dist/assets/chunk-G27WJ6UU-BLyQzdGE.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-B7HxxQds.js +1 -0
- package/web/dist/assets/chunk-LCL6LL3I-CtXrnUwF.js +206 -0
- package/web/dist/assets/chunk-POPQ4Y6H-dxddZlXt.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-DpkmrgHn.js +88 -0
- package/web/dist/assets/chunk-XXDRQBXY-BwZ7RbpQ.js +1 -0
- package/web/dist/assets/classDiagram-DTDB5LWJ-BGscw3OD.js +1 -0
- package/web/dist/assets/classDiagram-v2-JRS7N3AN-BGscw3OD.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-DGFOsMUt.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-DUwn3ehS.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-DlLAOZ9x.js +62 -0
- package/web/dist/assets/cytoscape.esm-BjAbmLdb.js +331 -0
- package/web/dist/assets/dagre-3AP2YEHR-CyourLfz.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-BTMbx2np.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-UK94_1-P.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-C7zS-v9v.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-DKJRa6CK.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-kUbAfGAp.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-CVW16_vb.js +1 -0
- package/web/dist/assets/erDiagram-SSCWMZ5O-BRQMFQ5y.js +99 -0
- package/web/dist/assets/flowDiagram-A5DVABFB-S34LpLtK.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-BFiqk1-G.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-LZsrdyyv.js +106 -0
- package/web/dist/assets/index-GAamh1p4.js +2152 -0
- package/web/dist/assets/infoDiagram-RXCK75RN-0DvN6KIp.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-CaY3A-Bx.js +70 -0
- package/web/dist/assets/journeyDiagram-EYS64GPL-BBeiaCrj.js +139 -0
- package/web/dist/assets/kanban-definition-3QL26DDD-ux42DRXQ.js +89 -0
- package/web/dist/assets/layout-CnXiF_NB.js +1 -0
- package/web/dist/assets/linear-BMW0u6G6.js +1 -0
- package/web/dist/assets/mindmap-definition-FBJOCRG2-hUxSpdKn.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-aCaQPH0w.js +1 -0
- package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-AcdOQ9Ij.js} +6 -6
- package/web/dist/assets/pieDiagram-E7YTZNPT-CmOEZ8zg.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-VW6fAhsV.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-CKJ7Ba5K.js +1 -0
- package/web/dist/assets/requirementDiagram-EFPCY7ZU-pNGkq9Vw.js +84 -0
- package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-KAfChbGV.js} +1 -1
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-BIMmDTfZ.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BBW7wzKY.js +162 -0
- package/web/dist/assets/sizeCapture-X5ZJPWSS-DdkXmLYR.js +1 -0
- package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-CDUlhUP7.js} +1 -1
- package/web/dist/assets/stateDiagram-HBIQ2CUA-W313Uz16.js +1 -0
- package/web/dist/assets/stateDiagram-v2-4QOOHH4V-DDhJBZzf.js +1 -0
- package/web/dist/assets/swimlanes-XN3QIQJK-C29TL26K.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BfN-Xdmu.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-CF0JMObX.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-DrDFsaH3.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-CVrnRIS_.js +78 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-BvkN1zsT.js +7 -0
- package/web/dist/index.html +17 -17
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
- package/web/dist/assets/SiteWindow-D6eS-2gM.js +0 -18
- package/web/dist/assets/index-Cbl3ATvp.js +0 -2125
package/server/lib/ssrf-guard.js
CHANGED
|
@@ -1,432 +1,432 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* server/lib/ssrf-guard.js — 出网地址闸(2026-08-18)
|
|
3
|
-
*
|
|
4
|
-
* ## 为什么要有这一层,以及为什么它必须在工具的实现体里
|
|
5
|
-
*
|
|
6
|
-
* agent 能自由指定 URL 的地方(截外部站、驱动浏览器)就是一台 SSRF 机器:它跑在
|
|
7
|
-
* 服务端的网络命名空间里,够得到我们自己的 API、exp 实例、本机各种监听端口、
|
|
8
|
-
* 云元数据地址。
|
|
9
|
-
*
|
|
10
|
-
* ⛔ **沙盒帮不上忙**:MCP 工具的 handler 全在 server 主进程里,而生产
|
|
11
|
-
* `NODESIGN_SANDBOX` 根本没开;即便开了,bwrap 和 `permissions.deny` 管的是 SDK 的
|
|
12
|
-
* Read/Write/Edit/Bash,**管不到 HTTP 路由和 MCP handler 的实现体**。所以这道闸
|
|
13
|
-
* 只能长在工具自己的代码里 —— 好处是 agent 关不掉它(它连沙盒都不碰)。
|
|
14
|
-
*
|
|
15
|
-
* ## 三条判据(缺一条都能被绕过)
|
|
16
|
-
*
|
|
17
|
-
* 1. **按解析出来的 IP 判,不按主机名判。** 词法检查挡得住 `127.0.0.1` 字面量,
|
|
18
|
-
* 挡不住一个 DNS 解析到 127.0.0.1 的公网域名。
|
|
19
|
-
* (`screenshot-url.js:30-49` 就是纯词法的那一档,这个模块是来替换它的。)
|
|
20
|
-
* 2. **每一个请求都要过,不只是第一个。** 302 的下一跳、iframe、`<img>`、fetch/XHR
|
|
21
|
-
* 的子资源各自都是请求。只查首个 URL 等于只锁前门。
|
|
22
|
-
* ⛔ **实测推翻过一个想当然的做法**:playwright 的 `context.route('**\/*')`
|
|
23
|
-
* **不拦跳转的下一跳** —— 本机 302 到 `https://example.com/`,浏览器实际到达了
|
|
24
|
-
* (`page.url()` 就是它),而 route 只被调用了一次(第一跳)。照那个做法,
|
|
25
|
-
* `https://evil/` → `http://127.0.0.1:4001/` 会直接穿过去。
|
|
26
|
-
* 改用 **CDP `Fetch.requestPaused`**,实测它拦得到:导航的跳转目标、
|
|
27
|
-
* **子资源的跳转目标**(`<img>` 302 到元数据)、以及全部子资源。
|
|
28
|
-
* 附带好处:响应体不绕道 node(route.fetch 那条路要代理全部流量,1 vCPU 付不起)。
|
|
29
|
-
* 3. ⭐ **本机自有 IP 也要禁。** 我们的 API 监听的是 `*:4001`(绑所有网卡,`ss` 实查),
|
|
30
|
-
* 所以只禁 `127.0.0.1` 挡不住"绕本机的 LAN/公网 IP 打自己"。
|
|
31
|
-
*
|
|
32
|
-
* ## 已知残余风险(诚实标注)
|
|
33
|
-
*
|
|
34
|
-
* **DNS 重绑定是缓解不是根除。** 我们在 route 层解析一次,playwright 真连的时候
|
|
35
|
-
* 会再解析一次,两次之间存在 TOCTOU 窗口。第二道防线是连上之后看 CDP 报的
|
|
36
|
-
* `remoteIPAddress`,但那已经是"数据可能开始返回"之后的补救。要根除得自己解析→
|
|
37
|
-
* pin 住 IP→带 Host 头连,playwright 的 route 模型做不干净。
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import os from 'node:os';
|
|
41
|
-
import dns from 'node:dns/promises';
|
|
42
|
-
import net from 'node:net';
|
|
43
|
-
|
|
44
|
-
/** IPv4 段(CIDR),含各类保留段 —— 不只私网,元数据和 CGNAT 也在里面 */
|
|
45
|
-
const V4_BLOCKS = [
|
|
46
|
-
['0.0.0.0', 8], // 本网络
|
|
47
|
-
['10.0.0.0', 8], // RFC1918
|
|
48
|
-
['100.64.0.0', 10], // CGNAT
|
|
49
|
-
['127.0.0.0', 8], // loopback
|
|
50
|
-
['169.254.0.0', 16], // link-local,含 GCP/AWS 元数据 169.254.169.254
|
|
51
|
-
['172.16.0.0', 12], // RFC1918
|
|
52
|
-
['192.0.0.0', 24], // IETF 协议分配
|
|
53
|
-
['192.0.2.0', 24], // TEST-NET-1
|
|
54
|
-
['192.88.99.0', 24], // 6to4 relay(已弃用)
|
|
55
|
-
['192.168.0.0', 16], // RFC1918
|
|
56
|
-
['198.18.0.0', 15], // 基准测试
|
|
57
|
-
['198.51.100.0', 24], // TEST-NET-2
|
|
58
|
-
['203.0.113.0', 24], // TEST-NET-3
|
|
59
|
-
['224.0.0.0', 4], // 组播
|
|
60
|
-
['240.0.0.0', 4], // 保留(含 255.255.255.255)
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
/** IPv6 前缀(小写、去零压缩前的字面前缀匹配即可覆盖) */
|
|
64
|
-
const V6_PREFIXES = ['::1', '::', 'fc', 'fd', 'fe8', 'fe9', 'fea', 'feb', 'ff'];
|
|
65
|
-
|
|
66
|
-
const v4ToInt = (ip) => ip.split('.').reduce((a, o) => (a << 8 >>> 0) + (Number(o) & 255), 0) >>> 0;
|
|
67
|
-
|
|
68
|
-
function v4Blocked(ip) {
|
|
69
|
-
const n = v4ToInt(ip);
|
|
70
|
-
for (const [base, bits] of V4_BLOCKS) {
|
|
71
|
-
const mask = bits === 0 ? 0 : (0xFFFFFFFF << (32 - bits)) >>> 0;
|
|
72
|
-
if ((n & mask) === (v4ToInt(base) & mask)) return true;
|
|
73
|
-
}
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* IPv6 字面量 → 16 字节。**认不出就返 null,调用方要 fail closed。**
|
|
79
|
-
*
|
|
80
|
-
* ⚠️ 为什么不再靠字符串前缀 + 几个正则:那套只认它见过的写法。审查实测三种写法
|
|
81
|
-
* 从第二道闸下直接走过去:
|
|
82
|
-
* `0:0:0:0:0:0:0:1`(::1 的展开形)、`::7f00:1`(IPv4-compatible 的十六进制形)、
|
|
83
|
-
* `0177.0.0.1`(八进制的 127.0.0.1)。
|
|
84
|
-
* 第一道闸(checkUrl)因为走 WHATWG URL 规范化不受影响,但第二道闸拿的是
|
|
85
|
-
* CDP 的 `remoteIPAddress`,判据自己得会规范化。**先规范化再判,别列写法。**
|
|
86
|
-
*/
|
|
87
|
-
function v6Bytes(ip) {
|
|
88
|
-
if (!net.isIPv6(ip)) return null;
|
|
89
|
-
let head = ip, tail = '';
|
|
90
|
-
const dbl = ip.indexOf('::');
|
|
91
|
-
if (dbl !== -1) { head = ip.slice(0, dbl); tail = ip.slice(dbl + 2); }
|
|
92
|
-
const toGroups = (part) => {
|
|
93
|
-
if (!part) return [];
|
|
94
|
-
const gs = part.split(':');
|
|
95
|
-
const out = [];
|
|
96
|
-
for (const g of gs) {
|
|
97
|
-
if (g.includes('.')) { // 尾部内嵌的点分四段
|
|
98
|
-
const o = g.split('.').map(Number);
|
|
99
|
-
if (o.length !== 4 || o.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
100
|
-
out.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
|
|
101
|
-
} else {
|
|
102
|
-
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
|
|
103
|
-
out.push(parseInt(g, 16));
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return out;
|
|
107
|
-
};
|
|
108
|
-
const h = toGroups(head), t = toGroups(tail);
|
|
109
|
-
if (h === null || t === null) return null;
|
|
110
|
-
const fill = 8 - h.length - t.length;
|
|
111
|
-
if (dbl === -1 ? fill !== 0 : fill < 0) return null;
|
|
112
|
-
const groups = [...h, ...new Array(dbl === -1 ? 0 : fill).fill(0), ...t];
|
|
113
|
-
if (groups.length !== 8) return null;
|
|
114
|
-
const b = [];
|
|
115
|
-
for (const g of groups) b.push((g >> 8) & 255, g & 255);
|
|
116
|
-
return b;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** 严格十进制点分四段(不接受前导零/十六进制/少于四段 —— 那些交给 fail closed) */
|
|
120
|
-
function strictV4(ip) {
|
|
121
|
-
const p = ip.split('.');
|
|
122
|
-
if (p.length !== 4) return null;
|
|
123
|
-
for (const o of p) {
|
|
124
|
-
if (!/^(0|[1-9]\d{0,2})$/.test(o) || Number(o) > 255) return null;
|
|
125
|
-
}
|
|
126
|
-
return ip;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** IPv4-mapped / NAT64 / IPv4-compatible 要**拆开按 v4 判**,否则直接绕过 */
|
|
130
|
-
function unwrapV6(ip) {
|
|
131
|
-
const b = v6Bytes(ip);
|
|
132
|
-
if (!b) {
|
|
133
|
-
// NAT64 的 well-known 前缀写法(64:ff9b::a.b.c.d 已被 v6Bytes 覆盖,
|
|
134
|
-
// 这里只兜住 net.isIPv6 认不出的怪写法 → 交给调用方 fail closed)
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
const zeros12 = b.slice(0, 12).every(x => x === 0);
|
|
138
|
-
const mapped = b.slice(0, 10).every(x => x === 0) && b[10] === 0xff && b[11] === 0xff;
|
|
139
|
-
// 64:ff9b::/96 是 NAT64
|
|
140
|
-
const nat64 = b[0] === 0x00 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b
|
|
141
|
-
&& b.slice(4, 12).every(x => x === 0);
|
|
142
|
-
// ⭐ zeros12 覆盖 `::a.b.c.d` 和 `::7f00:1`(IPv4-compatible,废弃但可路由到 v4)
|
|
143
|
-
if (mapped || nat64 || (zeros12 && !(b[12] === 0 && b[13] === 0 && b[14] === 0 && b[15] <= 1))) {
|
|
144
|
-
return `${b[12]}.${b[13]}.${b[14]}.${b[15]}`;
|
|
145
|
-
}
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* 本机的地址集合。
|
|
151
|
-
*
|
|
152
|
-
* ⛔ **只枚举网卡是不够的**(2026-08-18 审查攻出来的):云上是 1:1 NAT,
|
|
153
|
-
* 实例的**公网 IP 根本不在任何网卡上**。实测这台机器网卡只有
|
|
154
|
-
* `127.0.0.1 / ::1 / 10.128.0.12 / fe80::…`,而它的公网 IP 是 35.209.189.19 ——
|
|
155
|
-
* `blockReason` 对它返回 null(放行),发夹 `curl https://<公网IP>:8443/` 真的 200。
|
|
156
|
-
* 也就是说「本机自有 IP 也要禁」那条判据在原来的实现下**形同没有**,
|
|
157
|
-
* 而给它写的那条单元测试是同义反复(枚举 ownAddresses 再断言它们被禁,
|
|
158
|
-
* 结构上不可能失败)。
|
|
159
|
-
*
|
|
160
|
-
* 所以三个来源并起来:
|
|
161
|
-
* ① 网卡(拿到 loopback 和内网地址)
|
|
162
|
-
* ② 云元数据里的外部 IP(**我们自己进程去读**,不是让浏览器去读 —— 那个地址
|
|
163
|
-
* 对浏览器是禁的)。取不到就算了,别的环境没这个端点。
|
|
164
|
-
* ③ `ND_PUBLIC_IP` 环境变量兜底(自建机器、多 IP、或者元数据端点关掉的情况)
|
|
165
|
-
*
|
|
166
|
-
* ⚠️ ② 是**异步**的,所以 `primeOwnAddresses()` 要在启动时调一次;没调也不会崩,
|
|
167
|
-
* 只是少了那一条(同步路径永远拿得到 ① 和 ③)。
|
|
168
|
-
*/
|
|
169
|
-
/**
|
|
170
|
-
* 网卡枚举**不能永久 memo**:DHCP 续租换地址、加一张网卡、docker0 起来都会变,
|
|
171
|
-
* 而这个进程一跑几周(`enp0s0` 的 `valid_lft` 是几万秒)。缓存久了就是
|
|
172
|
-
* "闸按一份过期的地图判"。30 秒足够摊掉每个子资源都枚举一次的开销。
|
|
173
|
-
* ⚠️ `primed` 单独存:云 NAT 的公网 IP 不在任何网卡上,重新枚举会把它冲掉。
|
|
174
|
-
*/
|
|
175
|
-
const OWN_TTL_MS = 30_000;
|
|
176
|
-
let ownCache = null;
|
|
177
|
-
let ownCacheAt = 0;
|
|
178
|
-
const primed = new Set();
|
|
179
|
-
function baseOwn() {
|
|
180
|
-
const set = new Set(primed);
|
|
181
|
-
for (const list of Object.values(os.networkInterfaces())) {
|
|
182
|
-
for (const a of list || []) set.add(String(a.address).toLowerCase().replace(/%.*$/, ''));
|
|
183
|
-
}
|
|
184
|
-
for (const extra of String(process.env.ND_PUBLIC_IP || '').split(',')) {
|
|
185
|
-
const v = extra.trim().toLowerCase();
|
|
186
|
-
if (v) set.add(v);
|
|
187
|
-
}
|
|
188
|
-
return set;
|
|
189
|
-
}
|
|
190
|
-
export function ownAddresses() {
|
|
191
|
-
if (!ownCache || Date.now() - ownCacheAt > OWN_TTL_MS) {
|
|
192
|
-
ownCache = baseOwn();
|
|
193
|
-
ownCacheAt = Date.now();
|
|
194
|
-
}
|
|
195
|
-
return ownCache;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* 把云元数据里的外部 IP 也加进来。启动时调一次(失败静默 —— 不是所有环境都有)。
|
|
200
|
-
* @returns {Promise<string|null>} 加进来的那个 IP
|
|
201
|
-
*/
|
|
202
|
-
export async function primeOwnAddresses({ timeoutMs = 1500 } = {}) {
|
|
203
|
-
const set = ownAddresses();
|
|
204
|
-
const url = 'http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip';
|
|
205
|
-
try {
|
|
206
|
-
const ac = new AbortController();
|
|
207
|
-
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
208
|
-
const res = await fetch(url, { headers: { 'Metadata-Flavor': 'Google' }, signal: ac.signal });
|
|
209
|
-
clearTimeout(t);
|
|
210
|
-
if (!res.ok) return null;
|
|
211
|
-
const ip = (await res.text()).trim().toLowerCase();
|
|
212
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
|
|
213
|
-
primed.add(ip); // 存进不会被重新枚举冲掉的那一份
|
|
214
|
-
set.add(ip);
|
|
215
|
-
console.log(`[ssrf-guard] 本机公网 IP ${ip} 已加入禁止集(云 NAT 下它不在任何网卡上)`);
|
|
216
|
-
return ip;
|
|
217
|
-
}
|
|
218
|
-
} catch { /* 没这个端点 / 超时:正常 */ }
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* 这个 IP 该不该禁。
|
|
224
|
-
* @returns {string|null} 禁的理由;null = 放行
|
|
225
|
-
*/
|
|
226
|
-
export function blockReason(rawIp) {
|
|
227
|
-
if (!rawIp || typeof rawIp !== 'string') return 'empty address';
|
|
228
|
-
const ip = rawIp.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '');
|
|
229
|
-
|
|
230
|
-
if (ownAddresses().has(ip)) {
|
|
231
|
-
return `${ip} is this machine's own address (our API listens on *:PORT — reaching it via any local NIC is the same as reaching localhost)`;
|
|
232
|
-
}
|
|
233
|
-
if (ip.includes('.') && !ip.includes(':')) {
|
|
234
|
-
// ⭐ **严格十进制才放过判定**。`0177.0.0.1` 是八进制的 127.0.0.1,
|
|
235
|
-
// 老的 `\d+\.\d+\.\d+\.\d+` 正则会把它当合法四段、按 `177` 去算 → 放行。
|
|
236
|
-
const v4 = strictV4(ip);
|
|
237
|
-
if (!v4) return `${rawIp} is not a canonical IPv4 address (leading zeros / hex forms are refused rather than guessed)`;
|
|
238
|
-
return v4Blocked(v4) ? `${ip} is a private/reserved IPv4 address` : null;
|
|
239
|
-
}
|
|
240
|
-
if (ip.includes(':')) {
|
|
241
|
-
const v4 = unwrapV6(ip);
|
|
242
|
-
if (v4) {
|
|
243
|
-
if (ownAddresses().has(v4)) return `${ip} maps to this machine's own address`;
|
|
244
|
-
return v4Blocked(v4) ? `${ip} maps to private IPv4 ${v4}` : null;
|
|
245
|
-
}
|
|
246
|
-
// 规范化成 8 组再判,别拿字面量比前缀 —— `0:0:0:0:0:0:0:1` 就是 `::1`
|
|
247
|
-
const b = v6Bytes(ip);
|
|
248
|
-
if (!b) return `${rawIp} is not a canonical IPv6 address`;
|
|
249
|
-
const canon = [];
|
|
250
|
-
for (let i = 0; i < 16; i += 2) canon.push(((b[i] << 8) | b[i + 1]).toString(16));
|
|
251
|
-
const loopback = b.slice(0, 15).every(x => x === 0) && b[15] === 1;
|
|
252
|
-
const unspecified = b.every(x => x === 0);
|
|
253
|
-
if (loopback || unspecified) return `${ip} is the IPv6 loopback/unspecified address`;
|
|
254
|
-
if (ownAddresses().has(canon.join(':').replace(/(^|:)(0:)+/, '::').replace(/::+/, '::'))) {
|
|
255
|
-
return `${ip} is this machine's own address`;
|
|
256
|
-
}
|
|
257
|
-
const first = b[0];
|
|
258
|
-
// fc00::/7 唯一本地、fe80::/10 链路本地、ff00::/8 组播
|
|
259
|
-
if ((first & 0xfe) === 0xfc) return `${ip} is a unique-local IPv6 address (fc00::/7)`;
|
|
260
|
-
if (first === 0xfe && (b[1] & 0xc0) === 0x80) return `${ip} is a link-local IPv6 address (fe80::/10)`;
|
|
261
|
-
if (first === 0xff) return `${ip} is an IPv6 multicast address`;
|
|
262
|
-
return null;
|
|
263
|
-
}
|
|
264
|
-
return `${rawIp} is not an IP address`; // 调用方该先解析
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** 只允许这两个 scheme 发起网络请求;data:/blob: 不出网所以另算 */
|
|
268
|
-
const NET_SCHEMES = new Set(['http:', 'https:']);
|
|
269
|
-
const OFFLINE_SCHEMES = new Set(['data:', 'blob:', 'about:']);
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* 一个 URL 能不能让浏览器去。**解析 DNS 后逐个 IP 判**。
|
|
273
|
-
* @returns {Promise<{ok: true, ips: string[]} | {ok: false, reason: string}>}
|
|
274
|
-
*/
|
|
275
|
-
/**
|
|
276
|
-
* 主机名 → 判定结果的短期缓存。**只缓 DENY,不缓 ALLOW。**
|
|
277
|
-
*
|
|
278
|
-
* 一个页面几十个子资源,每个都查一次 DNS 在 1 vCPU 上不划算。但缓存方向不对称:
|
|
279
|
-
*
|
|
280
|
-
* ⛔ 原来两种都缓,注释写的是「TTL 内认一次结果,**缩小**了两次解析之间答案变了
|
|
281
|
-
* 的窗口」。这句话只在「先 DENY 后翻成公网」时成立。反方向 ——
|
|
282
|
-
* **先拿一个公网名骗到 ALLOW,再把解析翻向 127.0.0.1** —— 缓存把窗口从
|
|
283
|
-
* 「两次 DNS 之间」**放大成保证的 30 秒**:这道闸在 30 秒里根本不查 DNS。
|
|
284
|
-
* 审查时确定性复现过。
|
|
285
|
-
* ⭐ 缓 DENY 没有这个方向问题:最坏是把一个刚变干净的主机多拦 30 秒。
|
|
286
|
-
* 拦错了的代价是"看不到一个参考站",放错了的代价是内网。
|
|
287
|
-
*
|
|
288
|
-
* 真正的强制点在 `lib/browse-proxy.js`:它**每个请求**重解析并连**验过的那个 IP**
|
|
289
|
-
* (不给系统第二次解析的机会),所以常驻浏览器那条路对重绑定是免疫的。这里的
|
|
290
|
-
* checkUrl 是预筛 + `screenshot_url` 用(后者也已经走同一个代理)。
|
|
291
|
-
*/
|
|
292
|
-
const HOST_TTL_MS = 30_000;
|
|
293
|
-
const hostCache = new Map(); // host → { at, verdict }
|
|
294
|
-
|
|
295
|
-
export async function checkUrl(raw, { timeoutMs = 4000 } = {}) {
|
|
296
|
-
let u;
|
|
297
|
-
try { u = new URL(String(raw)); } catch { return { ok: false, reason: `not a valid URL: ${raw}` }; }
|
|
298
|
-
|
|
299
|
-
if (OFFLINE_SCHEMES.has(u.protocol)) return { ok: true, ips: [] }; // 不出网
|
|
300
|
-
if (!NET_SCHEMES.has(u.protocol)) {
|
|
301
|
-
return { ok: false, reason: `scheme ${u.protocol} is not allowed (only http/https; file:// would read local files)` };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
305
|
-
// 字面量 IP 不用解析(也别交给 DNS —— 有些解析器会对畸形量做意外的事)
|
|
306
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(':')) {
|
|
307
|
-
const why = blockReason(host);
|
|
308
|
-
return why ? { ok: false, reason: why } : { ok: true, ips: [host] };
|
|
309
|
-
}
|
|
310
|
-
// `.local` 是 mDNS,只可能指向局域网
|
|
311
|
-
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
|
|
312
|
-
return { ok: false, reason: `${host} resolves inside the local network` };
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const cached = hostCache.get(host);
|
|
316
|
-
if (cached && Date.now() - cached.at < HOST_TTL_MS) return cached.verdict;
|
|
317
|
-
|
|
318
|
-
let addrs;
|
|
319
|
-
try {
|
|
320
|
-
addrs = await Promise.race([
|
|
321
|
-
dns.lookup(host, { all: true, verbatim: true }),
|
|
322
|
-
new Promise((_, rej) => setTimeout(() => rej(new Error('dns timeout')), timeoutMs)),
|
|
323
|
-
]);
|
|
324
|
-
} catch (err) {
|
|
325
|
-
// kind:'dns' 给上层分文案用:解析失败不是策略拦截,别让调用方把这两种拒
|
|
326
|
-
// 混着说(agent 上报时曾据「硬边界」措辞推出一整套错误的排障理论)
|
|
327
|
-
return { ok: false, reason: `cannot resolve ${host}: ${err.message}`, kind: 'dns' };
|
|
328
|
-
}
|
|
329
|
-
if (!addrs.length) return { ok: false, reason: `${host} resolved to nothing`, kind: 'dns' };
|
|
330
|
-
|
|
331
|
-
// ⭐ **任一**地址落在禁止段就整个拒。多 A 记录里混一条 127.0.0.1 是标准的
|
|
332
|
-
// DNS-rebinding 起手式,"挑一个能用的"等于自己把门打开。
|
|
333
|
-
let verdict = { ok: true, ips: addrs.map(a => a.address) };
|
|
334
|
-
for (const a of addrs) {
|
|
335
|
-
const why = blockReason(a.address);
|
|
336
|
-
if (why) { verdict = { ok: false, reason: `${host} → ${why}` }; break; }
|
|
337
|
-
}
|
|
338
|
-
if (!verdict.ok) { // 只缓 DENY,见上面
|
|
339
|
-
hostCache.set(host, { at: Date.now(), verdict });
|
|
340
|
-
if (hostCache.size > 500) hostCache.delete(hostCache.keys().next().value);
|
|
341
|
-
}
|
|
342
|
-
return verdict;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* 把闸装到一个 playwright BrowserContext 上。两道防线:
|
|
347
|
-
*
|
|
348
|
-
* ① **CDP `Fetch.requestPaused`** —— 每个请求(含每个跳转下一跳、iframe、子资源)
|
|
349
|
-
* 在**发出之前**被暂停,按解析出的 IP 判,不过就 `Fetch.failRequest`。
|
|
350
|
-
* 为什么不是 playwright 的 `context.route`:实测它看不到跳转的下一跳(见文件头)。
|
|
351
|
-
* ② CDP `Network.responseReceived` 的 `remoteIPAddress` —— 真连上之后再看一眼对端,
|
|
352
|
-
* 挡第一道之后被重绑定掉的那种。**这是补救不是预防**:走到这里数据可能已经在返回。
|
|
353
|
-
*
|
|
354
|
-
* ⚠️⚠️ **`armPage` 必须 await 完才能让那个页面导航。**
|
|
355
|
-
* 第一版把装闸挂在 `context.on('page')` 里 fire-and-forget,结果**攻出一个初始化
|
|
356
|
-
* 竞态**:`newPage()` 之后立刻 goto,`Fetch.enable` 还没握手完,第一次导航整个
|
|
357
|
-
* 穿过第一道闸 —— 实测直接到达了 `169.254.169.254`,闸零记账(救回来的是第二道,
|
|
358
|
-
* 而那时数据已经在返回了)。所以现在装闸是**调用方必须显式 await 的动作**。
|
|
359
|
-
* 我们自己造页面,所以这条能保证;**没经过 armPage 的页面(比如 target=_blank
|
|
360
|
-
* 弹窗)一律立刻关掉** —— 一个没装闸的页面就是一个洞,宁可功能少一点。
|
|
361
|
-
*
|
|
362
|
-
* @param {import('playwright').BrowserContext} context
|
|
363
|
-
* @param {(ev: {url: string, reason: string, stage: string}) => void} [onBlocked]
|
|
364
|
-
* @returns {Promise<{ blocked: Array, armPage: (page) => Promise<void> }>}
|
|
365
|
-
*/
|
|
366
|
-
export async function attachSsrfGuard(context, onBlocked, { proxied = false } = {}) {
|
|
367
|
-
const blocked = [];
|
|
368
|
-
const armed = new WeakSet();
|
|
369
|
-
const note = (url, reason, stage) => {
|
|
370
|
-
const rec = { url: String(url).slice(0, 300), reason, stage };
|
|
371
|
-
blocked.push(rec);
|
|
372
|
-
if (blocked.length > 200) blocked.shift();
|
|
373
|
-
try { onBlocked?.(rec); } catch { /* 记账不能变成新故障源 */ }
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
async function armPage(page) {
|
|
377
|
-
if (armed.has(page)) return;
|
|
378
|
-
const cdp = await context.newCDPSession(page); // 抛错就让调用方知道(别静默假装装上了)
|
|
379
|
-
|
|
380
|
-
cdp.on('Fetch.requestPaused', async (ev) => {
|
|
381
|
-
// ⚠️ **每条路径都必须落到 continueRequest 或 failRequest 上**。handler 里抛错
|
|
382
|
-
// 会让这个请求**永远暂停**,浏览器一直等 —— 症状跟"网站很慢"一模一样,
|
|
383
|
-
// 真跑时撞过一次 30 秒超时。
|
|
384
|
-
const id = ev.requestId;
|
|
385
|
-
const url = ev.request?.url || '(unknown)';
|
|
386
|
-
try {
|
|
387
|
-
const verdict = await checkUrl(url);
|
|
388
|
-
if (verdict.ok) return await cdp.send('Fetch.continueRequest', { requestId: id });
|
|
389
|
-
note(url, verdict.reason, 'request');
|
|
390
|
-
return await cdp.send('Fetch.failRequest', { requestId: id, errorReason: 'AccessDenied' });
|
|
391
|
-
} catch (err) {
|
|
392
|
-
if (/closed|Target|detached|Session/i.test(err?.message || '')) return; // 正常竞态
|
|
393
|
-
// 判不出来就**拒**(fail-closed):安全闸不能因为自己出错而放行
|
|
394
|
-
note(url, `guard error, denied by default: ${err?.message || err}`, 'request');
|
|
395
|
-
try { await cdp.send('Fetch.failRequest', { requestId: id, errorReason: 'Failed' }); } catch { /* */ }
|
|
396
|
-
}
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// 第二道只在**没有代理**的通道上有意义。
|
|
400
|
-
// ⚠️ 真跑抓到的:挂了 browse-proxy 之后每个响应的 `remoteIPAddress` 都是代理
|
|
401
|
-
// 自己(127.0.0.1),于是这道兜底把**每个页面**都掐掉了。而它本来防的是
|
|
402
|
-
// 「请求阶段判过之后 DNS 被翻掉」—— 代理会自己解析并**连那个验过的 IP**(pin),
|
|
403
|
-
// 重绑定在那条路上结构性不可能,所以这道检查在代理下是纯冗余。
|
|
404
|
-
if (!proxied) {
|
|
405
|
-
cdp.on('Network.responseReceived', (ev) => {
|
|
406
|
-
const ip = ev?.response?.remoteIPAddress;
|
|
407
|
-
if (!ip) return;
|
|
408
|
-
const why = blockReason(ip);
|
|
409
|
-
if (!why) return;
|
|
410
|
-
note(ev.response.url, `connected to ${why}`, 'connected');
|
|
411
|
-
page.close().catch(() => {}); // 已经连上了,能做的只有立刻掐掉
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
await cdp.send('Network.enable');
|
|
416
|
-
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
|
|
417
|
-
armed.add(page);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// 没经过 armPage 的页面(弹窗)立刻关掉。给 armPage 一个微任务的机会先登记,
|
|
421
|
-
// 免得把我们自己刚造的页面误杀。
|
|
422
|
-
context.on('page', (p) => {
|
|
423
|
-
setTimeout(() => {
|
|
424
|
-
if (armed.has(p) || p.isClosed()) return;
|
|
425
|
-
note(p.url() || '(popup)', 'popup was not armed with the network guard — closed', 'popup');
|
|
426
|
-
p.close().catch(() => {});
|
|
427
|
-
}, 250);
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
for (const p of context.pages()) await armPage(p);
|
|
431
|
-
return { blocked, armPage };
|
|
432
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* server/lib/ssrf-guard.js — 出网地址闸(2026-08-18)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么要有这一层,以及为什么它必须在工具的实现体里
|
|
5
|
+
*
|
|
6
|
+
* agent 能自由指定 URL 的地方(截外部站、驱动浏览器)就是一台 SSRF 机器:它跑在
|
|
7
|
+
* 服务端的网络命名空间里,够得到我们自己的 API、exp 实例、本机各种监听端口、
|
|
8
|
+
* 云元数据地址。
|
|
9
|
+
*
|
|
10
|
+
* ⛔ **沙盒帮不上忙**:MCP 工具的 handler 全在 server 主进程里,而生产
|
|
11
|
+
* `NODESIGN_SANDBOX` 根本没开;即便开了,bwrap 和 `permissions.deny` 管的是 SDK 的
|
|
12
|
+
* Read/Write/Edit/Bash,**管不到 HTTP 路由和 MCP handler 的实现体**。所以这道闸
|
|
13
|
+
* 只能长在工具自己的代码里 —— 好处是 agent 关不掉它(它连沙盒都不碰)。
|
|
14
|
+
*
|
|
15
|
+
* ## 三条判据(缺一条都能被绕过)
|
|
16
|
+
*
|
|
17
|
+
* 1. **按解析出来的 IP 判,不按主机名判。** 词法检查挡得住 `127.0.0.1` 字面量,
|
|
18
|
+
* 挡不住一个 DNS 解析到 127.0.0.1 的公网域名。
|
|
19
|
+
* (`screenshot-url.js:30-49` 就是纯词法的那一档,这个模块是来替换它的。)
|
|
20
|
+
* 2. **每一个请求都要过,不只是第一个。** 302 的下一跳、iframe、`<img>`、fetch/XHR
|
|
21
|
+
* 的子资源各自都是请求。只查首个 URL 等于只锁前门。
|
|
22
|
+
* ⛔ **实测推翻过一个想当然的做法**:playwright 的 `context.route('**\/*')`
|
|
23
|
+
* **不拦跳转的下一跳** —— 本机 302 到 `https://example.com/`,浏览器实际到达了
|
|
24
|
+
* (`page.url()` 就是它),而 route 只被调用了一次(第一跳)。照那个做法,
|
|
25
|
+
* `https://evil/` → `http://127.0.0.1:4001/` 会直接穿过去。
|
|
26
|
+
* 改用 **CDP `Fetch.requestPaused`**,实测它拦得到:导航的跳转目标、
|
|
27
|
+
* **子资源的跳转目标**(`<img>` 302 到元数据)、以及全部子资源。
|
|
28
|
+
* 附带好处:响应体不绕道 node(route.fetch 那条路要代理全部流量,1 vCPU 付不起)。
|
|
29
|
+
* 3. ⭐ **本机自有 IP 也要禁。** 我们的 API 监听的是 `*:4001`(绑所有网卡,`ss` 实查),
|
|
30
|
+
* 所以只禁 `127.0.0.1` 挡不住"绕本机的 LAN/公网 IP 打自己"。
|
|
31
|
+
*
|
|
32
|
+
* ## 已知残余风险(诚实标注)
|
|
33
|
+
*
|
|
34
|
+
* **DNS 重绑定是缓解不是根除。** 我们在 route 层解析一次,playwright 真连的时候
|
|
35
|
+
* 会再解析一次,两次之间存在 TOCTOU 窗口。第二道防线是连上之后看 CDP 报的
|
|
36
|
+
* `remoteIPAddress`,但那已经是"数据可能开始返回"之后的补救。要根除得自己解析→
|
|
37
|
+
* pin 住 IP→带 Host 头连,playwright 的 route 模型做不干净。
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import os from 'node:os';
|
|
41
|
+
import dns from 'node:dns/promises';
|
|
42
|
+
import net from 'node:net';
|
|
43
|
+
|
|
44
|
+
/** IPv4 段(CIDR),含各类保留段 —— 不只私网,元数据和 CGNAT 也在里面 */
|
|
45
|
+
const V4_BLOCKS = [
|
|
46
|
+
['0.0.0.0', 8], // 本网络
|
|
47
|
+
['10.0.0.0', 8], // RFC1918
|
|
48
|
+
['100.64.0.0', 10], // CGNAT
|
|
49
|
+
['127.0.0.0', 8], // loopback
|
|
50
|
+
['169.254.0.0', 16], // link-local,含 GCP/AWS 元数据 169.254.169.254
|
|
51
|
+
['172.16.0.0', 12], // RFC1918
|
|
52
|
+
['192.0.0.0', 24], // IETF 协议分配
|
|
53
|
+
['192.0.2.0', 24], // TEST-NET-1
|
|
54
|
+
['192.88.99.0', 24], // 6to4 relay(已弃用)
|
|
55
|
+
['192.168.0.0', 16], // RFC1918
|
|
56
|
+
['198.18.0.0', 15], // 基准测试
|
|
57
|
+
['198.51.100.0', 24], // TEST-NET-2
|
|
58
|
+
['203.0.113.0', 24], // TEST-NET-3
|
|
59
|
+
['224.0.0.0', 4], // 组播
|
|
60
|
+
['240.0.0.0', 4], // 保留(含 255.255.255.255)
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** IPv6 前缀(小写、去零压缩前的字面前缀匹配即可覆盖) */
|
|
64
|
+
const V6_PREFIXES = ['::1', '::', 'fc', 'fd', 'fe8', 'fe9', 'fea', 'feb', 'ff'];
|
|
65
|
+
|
|
66
|
+
const v4ToInt = (ip) => ip.split('.').reduce((a, o) => (a << 8 >>> 0) + (Number(o) & 255), 0) >>> 0;
|
|
67
|
+
|
|
68
|
+
function v4Blocked(ip) {
|
|
69
|
+
const n = v4ToInt(ip);
|
|
70
|
+
for (const [base, bits] of V4_BLOCKS) {
|
|
71
|
+
const mask = bits === 0 ? 0 : (0xFFFFFFFF << (32 - bits)) >>> 0;
|
|
72
|
+
if ((n & mask) === (v4ToInt(base) & mask)) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* IPv6 字面量 → 16 字节。**认不出就返 null,调用方要 fail closed。**
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ 为什么不再靠字符串前缀 + 几个正则:那套只认它见过的写法。审查实测三种写法
|
|
81
|
+
* 从第二道闸下直接走过去:
|
|
82
|
+
* `0:0:0:0:0:0:0:1`(::1 的展开形)、`::7f00:1`(IPv4-compatible 的十六进制形)、
|
|
83
|
+
* `0177.0.0.1`(八进制的 127.0.0.1)。
|
|
84
|
+
* 第一道闸(checkUrl)因为走 WHATWG URL 规范化不受影响,但第二道闸拿的是
|
|
85
|
+
* CDP 的 `remoteIPAddress`,判据自己得会规范化。**先规范化再判,别列写法。**
|
|
86
|
+
*/
|
|
87
|
+
function v6Bytes(ip) {
|
|
88
|
+
if (!net.isIPv6(ip)) return null;
|
|
89
|
+
let head = ip, tail = '';
|
|
90
|
+
const dbl = ip.indexOf('::');
|
|
91
|
+
if (dbl !== -1) { head = ip.slice(0, dbl); tail = ip.slice(dbl + 2); }
|
|
92
|
+
const toGroups = (part) => {
|
|
93
|
+
if (!part) return [];
|
|
94
|
+
const gs = part.split(':');
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const g of gs) {
|
|
97
|
+
if (g.includes('.')) { // 尾部内嵌的点分四段
|
|
98
|
+
const o = g.split('.').map(Number);
|
|
99
|
+
if (o.length !== 4 || o.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
100
|
+
out.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
|
|
101
|
+
} else {
|
|
102
|
+
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
|
|
103
|
+
out.push(parseInt(g, 16));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
};
|
|
108
|
+
const h = toGroups(head), t = toGroups(tail);
|
|
109
|
+
if (h === null || t === null) return null;
|
|
110
|
+
const fill = 8 - h.length - t.length;
|
|
111
|
+
if (dbl === -1 ? fill !== 0 : fill < 0) return null;
|
|
112
|
+
const groups = [...h, ...new Array(dbl === -1 ? 0 : fill).fill(0), ...t];
|
|
113
|
+
if (groups.length !== 8) return null;
|
|
114
|
+
const b = [];
|
|
115
|
+
for (const g of groups) b.push((g >> 8) & 255, g & 255);
|
|
116
|
+
return b;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 严格十进制点分四段(不接受前导零/十六进制/少于四段 —— 那些交给 fail closed) */
|
|
120
|
+
function strictV4(ip) {
|
|
121
|
+
const p = ip.split('.');
|
|
122
|
+
if (p.length !== 4) return null;
|
|
123
|
+
for (const o of p) {
|
|
124
|
+
if (!/^(0|[1-9]\d{0,2})$/.test(o) || Number(o) > 255) return null;
|
|
125
|
+
}
|
|
126
|
+
return ip;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** IPv4-mapped / NAT64 / IPv4-compatible 要**拆开按 v4 判**,否则直接绕过 */
|
|
130
|
+
function unwrapV6(ip) {
|
|
131
|
+
const b = v6Bytes(ip);
|
|
132
|
+
if (!b) {
|
|
133
|
+
// NAT64 的 well-known 前缀写法(64:ff9b::a.b.c.d 已被 v6Bytes 覆盖,
|
|
134
|
+
// 这里只兜住 net.isIPv6 认不出的怪写法 → 交给调用方 fail closed)
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const zeros12 = b.slice(0, 12).every(x => x === 0);
|
|
138
|
+
const mapped = b.slice(0, 10).every(x => x === 0) && b[10] === 0xff && b[11] === 0xff;
|
|
139
|
+
// 64:ff9b::/96 是 NAT64
|
|
140
|
+
const nat64 = b[0] === 0x00 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b
|
|
141
|
+
&& b.slice(4, 12).every(x => x === 0);
|
|
142
|
+
// ⭐ zeros12 覆盖 `::a.b.c.d` 和 `::7f00:1`(IPv4-compatible,废弃但可路由到 v4)
|
|
143
|
+
if (mapped || nat64 || (zeros12 && !(b[12] === 0 && b[13] === 0 && b[14] === 0 && b[15] <= 1))) {
|
|
144
|
+
return `${b[12]}.${b[13]}.${b[14]}.${b[15]}`;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 本机的地址集合。
|
|
151
|
+
*
|
|
152
|
+
* ⛔ **只枚举网卡是不够的**(2026-08-18 审查攻出来的):云上是 1:1 NAT,
|
|
153
|
+
* 实例的**公网 IP 根本不在任何网卡上**。实测这台机器网卡只有
|
|
154
|
+
* `127.0.0.1 / ::1 / 10.128.0.12 / fe80::…`,而它的公网 IP 是 35.209.189.19 ——
|
|
155
|
+
* `blockReason` 对它返回 null(放行),发夹 `curl https://<公网IP>:8443/` 真的 200。
|
|
156
|
+
* 也就是说「本机自有 IP 也要禁」那条判据在原来的实现下**形同没有**,
|
|
157
|
+
* 而给它写的那条单元测试是同义反复(枚举 ownAddresses 再断言它们被禁,
|
|
158
|
+
* 结构上不可能失败)。
|
|
159
|
+
*
|
|
160
|
+
* 所以三个来源并起来:
|
|
161
|
+
* ① 网卡(拿到 loopback 和内网地址)
|
|
162
|
+
* ② 云元数据里的外部 IP(**我们自己进程去读**,不是让浏览器去读 —— 那个地址
|
|
163
|
+
* 对浏览器是禁的)。取不到就算了,别的环境没这个端点。
|
|
164
|
+
* ③ `ND_PUBLIC_IP` 环境变量兜底(自建机器、多 IP、或者元数据端点关掉的情况)
|
|
165
|
+
*
|
|
166
|
+
* ⚠️ ② 是**异步**的,所以 `primeOwnAddresses()` 要在启动时调一次;没调也不会崩,
|
|
167
|
+
* 只是少了那一条(同步路径永远拿得到 ① 和 ③)。
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* 网卡枚举**不能永久 memo**:DHCP 续租换地址、加一张网卡、docker0 起来都会变,
|
|
171
|
+
* 而这个进程一跑几周(`enp0s0` 的 `valid_lft` 是几万秒)。缓存久了就是
|
|
172
|
+
* "闸按一份过期的地图判"。30 秒足够摊掉每个子资源都枚举一次的开销。
|
|
173
|
+
* ⚠️ `primed` 单独存:云 NAT 的公网 IP 不在任何网卡上,重新枚举会把它冲掉。
|
|
174
|
+
*/
|
|
175
|
+
const OWN_TTL_MS = 30_000;
|
|
176
|
+
let ownCache = null;
|
|
177
|
+
let ownCacheAt = 0;
|
|
178
|
+
const primed = new Set();
|
|
179
|
+
function baseOwn() {
|
|
180
|
+
const set = new Set(primed);
|
|
181
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
182
|
+
for (const a of list || []) set.add(String(a.address).toLowerCase().replace(/%.*$/, ''));
|
|
183
|
+
}
|
|
184
|
+
for (const extra of String(process.env.ND_PUBLIC_IP || '').split(',')) {
|
|
185
|
+
const v = extra.trim().toLowerCase();
|
|
186
|
+
if (v) set.add(v);
|
|
187
|
+
}
|
|
188
|
+
return set;
|
|
189
|
+
}
|
|
190
|
+
export function ownAddresses() {
|
|
191
|
+
if (!ownCache || Date.now() - ownCacheAt > OWN_TTL_MS) {
|
|
192
|
+
ownCache = baseOwn();
|
|
193
|
+
ownCacheAt = Date.now();
|
|
194
|
+
}
|
|
195
|
+
return ownCache;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 把云元数据里的外部 IP 也加进来。启动时调一次(失败静默 —— 不是所有环境都有)。
|
|
200
|
+
* @returns {Promise<string|null>} 加进来的那个 IP
|
|
201
|
+
*/
|
|
202
|
+
export async function primeOwnAddresses({ timeoutMs = 1500 } = {}) {
|
|
203
|
+
const set = ownAddresses();
|
|
204
|
+
const url = 'http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip';
|
|
205
|
+
try {
|
|
206
|
+
const ac = new AbortController();
|
|
207
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
208
|
+
const res = await fetch(url, { headers: { 'Metadata-Flavor': 'Google' }, signal: ac.signal });
|
|
209
|
+
clearTimeout(t);
|
|
210
|
+
if (!res.ok) return null;
|
|
211
|
+
const ip = (await res.text()).trim().toLowerCase();
|
|
212
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
|
|
213
|
+
primed.add(ip); // 存进不会被重新枚举冲掉的那一份
|
|
214
|
+
set.add(ip);
|
|
215
|
+
console.log(`[ssrf-guard] 本机公网 IP ${ip} 已加入禁止集(云 NAT 下它不在任何网卡上)`);
|
|
216
|
+
return ip;
|
|
217
|
+
}
|
|
218
|
+
} catch { /* 没这个端点 / 超时:正常 */ }
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 这个 IP 该不该禁。
|
|
224
|
+
* @returns {string|null} 禁的理由;null = 放行
|
|
225
|
+
*/
|
|
226
|
+
export function blockReason(rawIp) {
|
|
227
|
+
if (!rawIp || typeof rawIp !== 'string') return 'empty address';
|
|
228
|
+
const ip = rawIp.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '');
|
|
229
|
+
|
|
230
|
+
if (ownAddresses().has(ip)) {
|
|
231
|
+
return `${ip} is this machine's own address (our API listens on *:PORT — reaching it via any local NIC is the same as reaching localhost)`;
|
|
232
|
+
}
|
|
233
|
+
if (ip.includes('.') && !ip.includes(':')) {
|
|
234
|
+
// ⭐ **严格十进制才放过判定**。`0177.0.0.1` 是八进制的 127.0.0.1,
|
|
235
|
+
// 老的 `\d+\.\d+\.\d+\.\d+` 正则会把它当合法四段、按 `177` 去算 → 放行。
|
|
236
|
+
const v4 = strictV4(ip);
|
|
237
|
+
if (!v4) return `${rawIp} is not a canonical IPv4 address (leading zeros / hex forms are refused rather than guessed)`;
|
|
238
|
+
return v4Blocked(v4) ? `${ip} is a private/reserved IPv4 address` : null;
|
|
239
|
+
}
|
|
240
|
+
if (ip.includes(':')) {
|
|
241
|
+
const v4 = unwrapV6(ip);
|
|
242
|
+
if (v4) {
|
|
243
|
+
if (ownAddresses().has(v4)) return `${ip} maps to this machine's own address`;
|
|
244
|
+
return v4Blocked(v4) ? `${ip} maps to private IPv4 ${v4}` : null;
|
|
245
|
+
}
|
|
246
|
+
// 规范化成 8 组再判,别拿字面量比前缀 —— `0:0:0:0:0:0:0:1` 就是 `::1`
|
|
247
|
+
const b = v6Bytes(ip);
|
|
248
|
+
if (!b) return `${rawIp} is not a canonical IPv6 address`;
|
|
249
|
+
const canon = [];
|
|
250
|
+
for (let i = 0; i < 16; i += 2) canon.push(((b[i] << 8) | b[i + 1]).toString(16));
|
|
251
|
+
const loopback = b.slice(0, 15).every(x => x === 0) && b[15] === 1;
|
|
252
|
+
const unspecified = b.every(x => x === 0);
|
|
253
|
+
if (loopback || unspecified) return `${ip} is the IPv6 loopback/unspecified address`;
|
|
254
|
+
if (ownAddresses().has(canon.join(':').replace(/(^|:)(0:)+/, '::').replace(/::+/, '::'))) {
|
|
255
|
+
return `${ip} is this machine's own address`;
|
|
256
|
+
}
|
|
257
|
+
const first = b[0];
|
|
258
|
+
// fc00::/7 唯一本地、fe80::/10 链路本地、ff00::/8 组播
|
|
259
|
+
if ((first & 0xfe) === 0xfc) return `${ip} is a unique-local IPv6 address (fc00::/7)`;
|
|
260
|
+
if (first === 0xfe && (b[1] & 0xc0) === 0x80) return `${ip} is a link-local IPv6 address (fe80::/10)`;
|
|
261
|
+
if (first === 0xff) return `${ip} is an IPv6 multicast address`;
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return `${rawIp} is not an IP address`; // 调用方该先解析
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 只允许这两个 scheme 发起网络请求;data:/blob: 不出网所以另算 */
|
|
268
|
+
const NET_SCHEMES = new Set(['http:', 'https:']);
|
|
269
|
+
const OFFLINE_SCHEMES = new Set(['data:', 'blob:', 'about:']);
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 一个 URL 能不能让浏览器去。**解析 DNS 后逐个 IP 判**。
|
|
273
|
+
* @returns {Promise<{ok: true, ips: string[]} | {ok: false, reason: string}>}
|
|
274
|
+
*/
|
|
275
|
+
/**
|
|
276
|
+
* 主机名 → 判定结果的短期缓存。**只缓 DENY,不缓 ALLOW。**
|
|
277
|
+
*
|
|
278
|
+
* 一个页面几十个子资源,每个都查一次 DNS 在 1 vCPU 上不划算。但缓存方向不对称:
|
|
279
|
+
*
|
|
280
|
+
* ⛔ 原来两种都缓,注释写的是「TTL 内认一次结果,**缩小**了两次解析之间答案变了
|
|
281
|
+
* 的窗口」。这句话只在「先 DENY 后翻成公网」时成立。反方向 ——
|
|
282
|
+
* **先拿一个公网名骗到 ALLOW,再把解析翻向 127.0.0.1** —— 缓存把窗口从
|
|
283
|
+
* 「两次 DNS 之间」**放大成保证的 30 秒**:这道闸在 30 秒里根本不查 DNS。
|
|
284
|
+
* 审查时确定性复现过。
|
|
285
|
+
* ⭐ 缓 DENY 没有这个方向问题:最坏是把一个刚变干净的主机多拦 30 秒。
|
|
286
|
+
* 拦错了的代价是"看不到一个参考站",放错了的代价是内网。
|
|
287
|
+
*
|
|
288
|
+
* 真正的强制点在 `lib/browse-proxy.js`:它**每个请求**重解析并连**验过的那个 IP**
|
|
289
|
+
* (不给系统第二次解析的机会),所以常驻浏览器那条路对重绑定是免疫的。这里的
|
|
290
|
+
* checkUrl 是预筛 + `screenshot_url` 用(后者也已经走同一个代理)。
|
|
291
|
+
*/
|
|
292
|
+
const HOST_TTL_MS = 30_000;
|
|
293
|
+
const hostCache = new Map(); // host → { at, verdict }
|
|
294
|
+
|
|
295
|
+
export async function checkUrl(raw, { timeoutMs = 4000 } = {}) {
|
|
296
|
+
let u;
|
|
297
|
+
try { u = new URL(String(raw)); } catch { return { ok: false, reason: `not a valid URL: ${raw}` }; }
|
|
298
|
+
|
|
299
|
+
if (OFFLINE_SCHEMES.has(u.protocol)) return { ok: true, ips: [] }; // 不出网
|
|
300
|
+
if (!NET_SCHEMES.has(u.protocol)) {
|
|
301
|
+
return { ok: false, reason: `scheme ${u.protocol} is not allowed (only http/https; file:// would read local files)` };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
305
|
+
// 字面量 IP 不用解析(也别交给 DNS —— 有些解析器会对畸形量做意外的事)
|
|
306
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(':')) {
|
|
307
|
+
const why = blockReason(host);
|
|
308
|
+
return why ? { ok: false, reason: why } : { ok: true, ips: [host] };
|
|
309
|
+
}
|
|
310
|
+
// `.local` 是 mDNS,只可能指向局域网
|
|
311
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
|
|
312
|
+
return { ok: false, reason: `${host} resolves inside the local network` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const cached = hostCache.get(host);
|
|
316
|
+
if (cached && Date.now() - cached.at < HOST_TTL_MS) return cached.verdict;
|
|
317
|
+
|
|
318
|
+
let addrs;
|
|
319
|
+
try {
|
|
320
|
+
addrs = await Promise.race([
|
|
321
|
+
dns.lookup(host, { all: true, verbatim: true }),
|
|
322
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('dns timeout')), timeoutMs)),
|
|
323
|
+
]);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
// kind:'dns' 给上层分文案用:解析失败不是策略拦截,别让调用方把这两种拒
|
|
326
|
+
// 混着说(agent 上报时曾据「硬边界」措辞推出一整套错误的排障理论)
|
|
327
|
+
return { ok: false, reason: `cannot resolve ${host}: ${err.message}`, kind: 'dns' };
|
|
328
|
+
}
|
|
329
|
+
if (!addrs.length) return { ok: false, reason: `${host} resolved to nothing`, kind: 'dns' };
|
|
330
|
+
|
|
331
|
+
// ⭐ **任一**地址落在禁止段就整个拒。多 A 记录里混一条 127.0.0.1 是标准的
|
|
332
|
+
// DNS-rebinding 起手式,"挑一个能用的"等于自己把门打开。
|
|
333
|
+
let verdict = { ok: true, ips: addrs.map(a => a.address) };
|
|
334
|
+
for (const a of addrs) {
|
|
335
|
+
const why = blockReason(a.address);
|
|
336
|
+
if (why) { verdict = { ok: false, reason: `${host} → ${why}` }; break; }
|
|
337
|
+
}
|
|
338
|
+
if (!verdict.ok) { // 只缓 DENY,见上面
|
|
339
|
+
hostCache.set(host, { at: Date.now(), verdict });
|
|
340
|
+
if (hostCache.size > 500) hostCache.delete(hostCache.keys().next().value);
|
|
341
|
+
}
|
|
342
|
+
return verdict;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 把闸装到一个 playwright BrowserContext 上。两道防线:
|
|
347
|
+
*
|
|
348
|
+
* ① **CDP `Fetch.requestPaused`** —— 每个请求(含每个跳转下一跳、iframe、子资源)
|
|
349
|
+
* 在**发出之前**被暂停,按解析出的 IP 判,不过就 `Fetch.failRequest`。
|
|
350
|
+
* 为什么不是 playwright 的 `context.route`:实测它看不到跳转的下一跳(见文件头)。
|
|
351
|
+
* ② CDP `Network.responseReceived` 的 `remoteIPAddress` —— 真连上之后再看一眼对端,
|
|
352
|
+
* 挡第一道之后被重绑定掉的那种。**这是补救不是预防**:走到这里数据可能已经在返回。
|
|
353
|
+
*
|
|
354
|
+
* ⚠️⚠️ **`armPage` 必须 await 完才能让那个页面导航。**
|
|
355
|
+
* 第一版把装闸挂在 `context.on('page')` 里 fire-and-forget,结果**攻出一个初始化
|
|
356
|
+
* 竞态**:`newPage()` 之后立刻 goto,`Fetch.enable` 还没握手完,第一次导航整个
|
|
357
|
+
* 穿过第一道闸 —— 实测直接到达了 `169.254.169.254`,闸零记账(救回来的是第二道,
|
|
358
|
+
* 而那时数据已经在返回了)。所以现在装闸是**调用方必须显式 await 的动作**。
|
|
359
|
+
* 我们自己造页面,所以这条能保证;**没经过 armPage 的页面(比如 target=_blank
|
|
360
|
+
* 弹窗)一律立刻关掉** —— 一个没装闸的页面就是一个洞,宁可功能少一点。
|
|
361
|
+
*
|
|
362
|
+
* @param {import('playwright').BrowserContext} context
|
|
363
|
+
* @param {(ev: {url: string, reason: string, stage: string}) => void} [onBlocked]
|
|
364
|
+
* @returns {Promise<{ blocked: Array, armPage: (page) => Promise<void> }>}
|
|
365
|
+
*/
|
|
366
|
+
export async function attachSsrfGuard(context, onBlocked, { proxied = false } = {}) {
|
|
367
|
+
const blocked = [];
|
|
368
|
+
const armed = new WeakSet();
|
|
369
|
+
const note = (url, reason, stage) => {
|
|
370
|
+
const rec = { url: String(url).slice(0, 300), reason, stage };
|
|
371
|
+
blocked.push(rec);
|
|
372
|
+
if (blocked.length > 200) blocked.shift();
|
|
373
|
+
try { onBlocked?.(rec); } catch { /* 记账不能变成新故障源 */ }
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
async function armPage(page) {
|
|
377
|
+
if (armed.has(page)) return;
|
|
378
|
+
const cdp = await context.newCDPSession(page); // 抛错就让调用方知道(别静默假装装上了)
|
|
379
|
+
|
|
380
|
+
cdp.on('Fetch.requestPaused', async (ev) => {
|
|
381
|
+
// ⚠️ **每条路径都必须落到 continueRequest 或 failRequest 上**。handler 里抛错
|
|
382
|
+
// 会让这个请求**永远暂停**,浏览器一直等 —— 症状跟"网站很慢"一模一样,
|
|
383
|
+
// 真跑时撞过一次 30 秒超时。
|
|
384
|
+
const id = ev.requestId;
|
|
385
|
+
const url = ev.request?.url || '(unknown)';
|
|
386
|
+
try {
|
|
387
|
+
const verdict = await checkUrl(url);
|
|
388
|
+
if (verdict.ok) return await cdp.send('Fetch.continueRequest', { requestId: id });
|
|
389
|
+
note(url, verdict.reason, 'request');
|
|
390
|
+
return await cdp.send('Fetch.failRequest', { requestId: id, errorReason: 'AccessDenied' });
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (/closed|Target|detached|Session/i.test(err?.message || '')) return; // 正常竞态
|
|
393
|
+
// 判不出来就**拒**(fail-closed):安全闸不能因为自己出错而放行
|
|
394
|
+
note(url, `guard error, denied by default: ${err?.message || err}`, 'request');
|
|
395
|
+
try { await cdp.send('Fetch.failRequest', { requestId: id, errorReason: 'Failed' }); } catch { /* */ }
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// 第二道只在**没有代理**的通道上有意义。
|
|
400
|
+
// ⚠️ 真跑抓到的:挂了 browse-proxy 之后每个响应的 `remoteIPAddress` 都是代理
|
|
401
|
+
// 自己(127.0.0.1),于是这道兜底把**每个页面**都掐掉了。而它本来防的是
|
|
402
|
+
// 「请求阶段判过之后 DNS 被翻掉」—— 代理会自己解析并**连那个验过的 IP**(pin),
|
|
403
|
+
// 重绑定在那条路上结构性不可能,所以这道检查在代理下是纯冗余。
|
|
404
|
+
if (!proxied) {
|
|
405
|
+
cdp.on('Network.responseReceived', (ev) => {
|
|
406
|
+
const ip = ev?.response?.remoteIPAddress;
|
|
407
|
+
if (!ip) return;
|
|
408
|
+
const why = blockReason(ip);
|
|
409
|
+
if (!why) return;
|
|
410
|
+
note(ev.response.url, `connected to ${why}`, 'connected');
|
|
411
|
+
page.close().catch(() => {}); // 已经连上了,能做的只有立刻掐掉
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await cdp.send('Network.enable');
|
|
416
|
+
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
|
|
417
|
+
armed.add(page);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 没经过 armPage 的页面(弹窗)立刻关掉。给 armPage 一个微任务的机会先登记,
|
|
421
|
+
// 免得把我们自己刚造的页面误杀。
|
|
422
|
+
context.on('page', (p) => {
|
|
423
|
+
setTimeout(() => {
|
|
424
|
+
if (armed.has(p) || p.isClosed()) return;
|
|
425
|
+
note(p.url() || '(popup)', 'popup was not armed with the network guard — closed', 'popup');
|
|
426
|
+
p.close().catch(() => {});
|
|
427
|
+
}, 250);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
for (const p of context.pages()) await armPage(p);
|
|
431
|
+
return { blocked, armPage };
|
|
432
|
+
}
|