@baique/pi-web-sky 0.1.35 → 0.1.36
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +16 -16
- package/.next/build-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +1 -1
- package/.next/required-server-files.js +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +3 -3
- package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/bash-output/route.js +1 -1
- package/.next/server/app/api/agent/[id]/events/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/app-update/route.js +1 -1
- package/.next/server/app/api/auth/login/[provider]/route.js +1 -1
- package/.next/server/app/api/cwd/validate/route.js +1 -1
- package/.next/server/app/api/default-cwd/route.js +1 -1
- package/.next/server/app/api/file-index/route.js +1 -1
- package/.next/server/app/api/files/[...path]/route.js +1 -1
- package/.next/server/app/api/mcp/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/project-trust/route.js +1 -1
- package/.next/server/app/api/search/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/auto-name/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/skills/check/route.js +1 -1
- package/.next/server/app/api/skills/check/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/install/route.js +1 -1
- package/.next/server/app/api/skills/route.js +2 -31
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/update/route.js +1 -1
- package/.next/server/app/api/skills/update/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +5 -5
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
- package/.next/server/app/index.segments/_full.segment.rsc +5 -5
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +2 -2
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +16 -16
- package/.next/server/chunks/2404.js +1 -1
- package/.next/server/chunks/3500.js +30 -0
- package/.next/server/chunks/4226.js +2 -2
- package/.next/server/chunks/542.js +10 -5
- package/.next/server/chunks/5794.js +1 -1
- package/.next/server/chunks/6429.js +1 -1
- package/.next/server/chunks/898.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/5315.7e72d26a3ad3bd9a.js +4 -0
- package/.next/static/chunks/app/{layout-1d9a7a44fbc92d2e.js → layout-ebbc0d287267b07b.js} +1 -1
- package/.next/static/chunks/app/{page-6d6779a81a0a7639.js → page-e568623a44c67606.js} +2 -2
- package/.next/static/chunks/{webpack-fa4f40085b1ec1ac.js → webpack-2e4e348867a555cd.js} +1 -1
- package/.next/static/css/422c7dc0faa04d89.css +1 -0
- package/.next/trace +8 -9
- package/.next/trace-build +1 -1
- package/bin/pi-web-sky.js +15 -20
- package/lib/agent-client.test.mjs +64 -0
- package/lib/agent-client.ts +53 -0
- package/lib/agent-event-connection.test.mjs +193 -0
- package/lib/agent-event-connection.ts +202 -0
- package/lib/agent-event-stream.test.mjs +155 -0
- package/lib/agent-event-stream.ts +132 -0
- package/lib/agent-event-wire.test.mjs +278 -0
- package/lib/agent-event-wire.ts +107 -0
- package/lib/agent-phase.ts +24 -0
- package/lib/allowed-roots.ts +31 -0
- package/lib/ansi.test.mjs +52 -0
- package/lib/ansi.ts +211 -0
- package/lib/api-types.ts +139 -0
- package/lib/app-update.test.mjs +27 -0
- package/lib/app-update.ts +28 -0
- package/lib/atomic-file.test.mjs +37 -0
- package/lib/atomic-file.ts +34 -0
- package/lib/audit-session.ts +235 -0
- package/lib/bash-output.test.mjs +63 -0
- package/lib/bash-output.ts +53 -0
- package/lib/bg-image.ts +129 -0
- package/lib/board-events.ts +83 -0
- package/lib/board-purge.test.mjs +66 -0
- package/lib/board-purge.ts +65 -0
- package/lib/board-search.test.mjs +110 -0
- package/lib/board-search.ts +63 -0
- package/lib/board-store.test.mjs +542 -0
- package/lib/board-store.ts +794 -0
- package/lib/board-types.ts +96 -0
- package/lib/board-utils.ts +17 -0
- package/lib/bounded-form-data.test.mjs +54 -0
- package/lib/bounded-form-data.ts +49 -0
- package/lib/browser-notifications.test.mjs +167 -0
- package/lib/browser-notifications.ts +104 -0
- package/lib/chat-lazy-load.test.mjs +140 -0
- package/lib/chat-lazy-load.ts +64 -0
- package/lib/clipboard-paths.test.mjs +140 -0
- package/lib/clipboard-paths.ts +192 -0
- package/lib/clipboard.ts +18 -0
- package/lib/compaction-summary.test.mjs +44 -0
- package/lib/compaction-summary.ts +34 -0
- package/lib/custom-ui-terminal.test.mjs +32 -0
- package/lib/custom-ui-terminal.ts +27 -0
- package/lib/deepseek-pricing.test.mjs +120 -0
- package/lib/deepseek-pricing.ts +71 -0
- package/lib/directory-browser.test.mjs +60 -0
- package/lib/directory-browser.ts +83 -0
- package/lib/draft-stash.test.mjs +91 -0
- package/lib/draft-stash.ts +102 -0
- package/lib/draft-store.ts +105 -0
- package/lib/dropdown-direction.ts +27 -0
- package/lib/extension-command.test.mjs +63 -0
- package/lib/extension-command.ts +81 -0
- package/lib/file-access.test.mjs +31 -0
- package/lib/file-access.ts +59 -0
- package/lib/file-dirent.test.mjs +56 -0
- package/lib/file-dirent.ts +17 -0
- package/lib/file-explorer-state.test.mjs +63 -0
- package/lib/file-explorer-state.ts +36 -0
- package/lib/file-fuzzy.test.mjs +29 -0
- package/lib/file-fuzzy.ts +189 -0
- package/lib/file-links.test.mjs +92 -0
- package/lib/file-links.ts +148 -0
- package/lib/file-paths.ts +43 -0
- package/lib/file-types.test.mjs +34 -0
- package/lib/file-types.ts +73 -0
- package/lib/file-upload.test.mjs +70 -0
- package/lib/file-upload.ts +59 -0
- package/lib/file-viewer-state.test.mjs +20 -0
- package/lib/file-viewer-state.ts +15 -0
- package/lib/frontmatter.test.mjs +55 -0
- package/lib/frontmatter.ts +87 -0
- package/lib/git-changes.test.mjs +52 -0
- package/lib/git-changes.ts +232 -0
- package/lib/git-status.ts +47 -0
- package/lib/git-types.ts +29 -0
- package/lib/http-dispatcher.test.mjs +83 -0
- package/lib/http-dispatcher.ts +86 -0
- package/lib/i18n/format.test.mjs +19 -0
- package/lib/i18n/format.ts +60 -0
- package/lib/i18n/messages/en.ts +662 -0
- package/lib/i18n/messages/zh-CN.ts +661 -0
- package/lib/i18n/registry.test.mjs +32 -0
- package/lib/i18n/registry.ts +43 -0
- package/lib/i18n/types.ts +15 -0
- package/lib/image-attachments.test.mjs +27 -0
- package/lib/image-attachments.ts +56 -0
- package/lib/initial-navigation.test.mjs +65 -0
- package/lib/initial-navigation.ts +15 -0
- package/lib/markdown.test.mjs +104 -0
- package/lib/markdown.ts +347 -0
- package/lib/message-display.test.mjs +130 -0
- package/lib/message-display.ts +47 -0
- package/lib/model-catalog.test.mjs +327 -0
- package/lib/model-catalog.ts +414 -0
- package/lib/model-discovery-auth.ts +58 -0
- package/lib/model-discovery.test.mjs +50 -0
- package/lib/model-discovery.ts +75 -0
- package/lib/model-id-preference.ts +46 -0
- package/lib/model-scope.test.mjs +193 -0
- package/lib/model-scope.ts +198 -0
- package/lib/models-cache.test.mjs +122 -0
- package/lib/models-cache.ts +90 -0
- package/lib/models-config-store.test.mjs +200 -0
- package/lib/models-config-store.ts +84 -0
- package/lib/next-config-esm.test.mjs +14 -0
- package/lib/next-config.test.mjs +13 -0
- package/lib/node-version.test.mjs +33 -0
- package/lib/normalize.test.mjs +54 -0
- package/lib/normalize.ts +63 -0
- package/lib/npx.ts +62 -0
- package/lib/panel-layout.test.mjs +56 -0
- package/lib/panel-layout.ts +52 -0
- package/lib/patch.test.mjs +110 -0
- package/lib/patch.ts +134 -0
- package/lib/path-security.ts +42 -0
- package/lib/paths.test.mjs +58 -0
- package/lib/paths.ts +71 -0
- package/lib/pi-types.ts +183 -0
- package/lib/pi-web-options.test.mjs +63 -0
- package/lib/process-lifecycle.test.mjs +83 -0
- package/lib/project-command-env.test.mjs +298 -0
- package/lib/project-command-env.ts +129 -0
- package/lib/project-groups.test.mjs +60 -0
- package/lib/project-groups.ts +53 -0
- package/lib/project-identity.test.mjs +37 -0
- package/lib/project-identity.ts +26 -0
- package/lib/project-tree.test.mjs +115 -0
- package/lib/project-tree.ts +176 -0
- package/lib/project-trust.test.mjs +117 -0
- package/lib/project-trust.ts +48 -0
- package/lib/prompt-recovery.test.mjs +33 -0
- package/lib/prompt-recovery.ts +47 -0
- package/lib/provider-api-key-route.test.mjs +12 -0
- package/lib/provider-credential-store.test.mjs +126 -0
- package/lib/provider-credential-store.ts +114 -0
- package/lib/provider-listing-runtime.ts +37 -0
- package/lib/provider-listing.test.mjs +114 -0
- package/lib/provider-listing.ts +118 -0
- package/lib/provider-services.ts +64 -0
- package/lib/request-security.test.mjs +256 -0
- package/lib/request-security.ts +136 -0
- package/lib/rpc-manager-shutdown.test.mjs +372 -0
- package/lib/rpc-manager-widgets.test.mjs +342 -0
- package/lib/rpc-manager.test.mjs +139 -0
- package/lib/rpc-manager.ts +1703 -0
- package/lib/rpc-session-info.test.mjs +93 -0
- package/lib/session-delete.test.mjs +53 -0
- package/lib/session-delete.ts +86 -0
- package/lib/session-file-references-core.ts +82 -0
- package/lib/session-file-references.test.mjs +91 -0
- package/lib/session-file-references.ts +29 -0
- package/lib/session-path.test.mjs +24 -0
- package/lib/session-path.ts +11 -0
- package/lib/session-reader.pagination.test.mjs +115 -0
- package/lib/session-reader.test.mjs +446 -0
- package/lib/session-reader.ts +548 -0
- package/lib/session-resolve.test.mjs +65 -0
- package/lib/session-row-context-menu.test.mjs +46 -0
- package/lib/session-row-context-menu.ts +29 -0
- package/lib/session-scanner.test.mjs +151 -0
- package/lib/session-scanner.ts +238 -0
- package/lib/session-search.test.mjs +173 -0
- package/lib/session-search.ts +284 -0
- package/lib/session-stats.ts +26 -0
- package/lib/session-timing.test.mjs +107 -0
- package/lib/session-timing.ts +45 -0
- package/lib/session-title.test.mjs +284 -0
- package/lib/session-title.ts +255 -0
- package/lib/skill-frontmatter.test.mjs +75 -0
- package/lib/skill-frontmatter.ts +40 -0
- package/lib/skill-lock.test.mjs +182 -0
- package/lib/skill-lock.ts +146 -0
- package/lib/skill-updates.test.mjs +182 -0
- package/lib/skill-updates.ts +263 -0
- package/lib/skills-service.ts +16 -0
- package/lib/slash-display.test.mjs +122 -0
- package/lib/slash-display.ts +30 -0
- package/lib/sqlite-db.test.mjs +140 -0
- package/lib/sqlite-db.ts +205 -0
- package/lib/startup-preferences.test.mjs +130 -0
- package/lib/startup-preferences.ts +65 -0
- package/lib/streaming-message.test.mjs +212 -0
- package/lib/streaming-message.ts +151 -0
- package/lib/subagent-widget.test.mjs +87 -0
- package/lib/subagent-widget.ts +109 -0
- package/lib/sync-room-server.mjs +137 -0
- package/lib/task-card-store.test.mjs +309 -0
- package/lib/task-card-store.ts +559 -0
- package/lib/task-scheduler.ts +498 -0
- package/lib/task-store.test.mjs +216 -0
- package/lib/task-store.ts +271 -0
- package/lib/terminal-input.test.mjs +46 -0
- package/lib/terminal-input.ts +63 -0
- package/lib/terminal-manager.test.mjs +71 -0
- package/lib/terminal-manager.ts +245 -0
- package/lib/tool-execution-progress.test.mjs +32 -0
- package/lib/tool-execution-progress.ts +29 -0
- package/lib/tool-names.ts +25 -0
- package/lib/tool-preset-preference.test.mjs +72 -0
- package/lib/tool-preset-preference.ts +41 -0
- package/lib/tool-presets.test.mjs +56 -0
- package/lib/tool-presets.ts +42 -0
- package/lib/turn-written-files.test.mjs +137 -0
- package/lib/turn-written-files.ts +60 -0
- package/lib/types.ts +349 -0
- package/lib/wallpaper-settings.ts +191 -0
- package/lib/web-auth.test.mjs +50 -0
- package/lib/web-auth.ts +45 -0
- package/lib/workspace-memory.test.mjs +129 -0
- package/lib/workspace-memory.ts +98 -0
- package/lib/worktree.test.mjs +50 -0
- package/lib/worktree.ts +250 -0
- package/package.json +3 -1
- package/server.mjs +34 -0
- package/.next/server/chunks/7513.js +0 -30
- package/.next/static/chunks/5315.03de3c6abd8b283e.js +0 -4
- package/.next/static/css/cf25164cb7c04d95.css +0 -1
- /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_buildManifest.js +0 -0
- /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
async function loadSubject() {
|
|
5
|
+
return import("./request-security.ts");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
test("allows same-origin and non-browser API requests", async () => {
|
|
9
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
10
|
+
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: {
|
|
13
|
+
host: "localhost:30141",
|
|
14
|
+
origin: "http://localhost:30141",
|
|
15
|
+
"sec-fetch-site": "same-origin",
|
|
16
|
+
},
|
|
17
|
+
})), true);
|
|
18
|
+
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { host: "localhost:30141" },
|
|
21
|
+
})), true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("allows LAN same-origin requests when Next.js uses an internal localhost URL", async () => {
|
|
25
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
26
|
+
const request = new Request("http://localhost:30141/api/test", {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
host: "192.168.32.7:30141",
|
|
30
|
+
origin: "http://192.168.32.7:30141",
|
|
31
|
+
"sec-fetch-site": "same-origin",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
assert.equal(isApiRequestAllowed(request), true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("allows IPv6 and an explicitly configured hostname", async () => {
|
|
38
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
39
|
+
const ipv6 = new Request("http://localhost:30141/api/test", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
host: "[::1]:30141",
|
|
43
|
+
origin: "http://[::1]:30141",
|
|
44
|
+
"sec-fetch-site": "same-origin",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const configured = new Request("http://localhost:30141/api/test", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {
|
|
50
|
+
host: "pi-web.internal:30141",
|
|
51
|
+
origin: "http://pi-web.internal:30141",
|
|
52
|
+
"sec-fetch-site": "same-origin",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
assert.equal(isApiRequestAllowed(ipv6), true);
|
|
56
|
+
assert.equal(isApiRequestAllowed(configured, ["pi-web.internal"]), true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("rejects cross-origin browser API requests", async () => {
|
|
60
|
+
const { isApiRequestAllowed, shouldCheckApiRequestOrigin } = await loadSubject();
|
|
61
|
+
const post = new Request("http://localhost:30141/api/test", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: {
|
|
64
|
+
host: "localhost:30141",
|
|
65
|
+
origin: "https://attacker.example",
|
|
66
|
+
"sec-fetch-site": "cross-site",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const crossSiteGet = new Request("http://localhost:30141/api/sessions", {
|
|
70
|
+
headers: { host: "localhost:30141", "sec-fetch-site": "cross-site" },
|
|
71
|
+
});
|
|
72
|
+
assert.equal(shouldCheckApiRequestOrigin(post), true);
|
|
73
|
+
assert.equal(isApiRequestAllowed(post), false);
|
|
74
|
+
assert.equal(shouldCheckApiRequestOrigin(crossSiteGet), true);
|
|
75
|
+
assert.equal(isApiRequestAllowed(crossSiteGet), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("does not globally trust opaque iframe or alternate loopback origins", async () => {
|
|
79
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
80
|
+
const previewUrl = "http://localhost:30141/api/files/tmp/test.docx?type=preview";
|
|
81
|
+
const opaqueIframe = new Request(previewUrl, {
|
|
82
|
+
headers: {
|
|
83
|
+
host: "localhost:30141",
|
|
84
|
+
origin: "null",
|
|
85
|
+
"sec-fetch-site": "cross-site",
|
|
86
|
+
"sec-fetch-mode": "navigate",
|
|
87
|
+
"sec-fetch-dest": "iframe",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const alternateLoopback = new Request(previewUrl, {
|
|
91
|
+
headers: {
|
|
92
|
+
host: "localhost:30141",
|
|
93
|
+
origin: "http://127.0.0.1:30141",
|
|
94
|
+
"sec-fetch-site": "cross-site",
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.equal(isApiRequestAllowed(opaqueIframe), false);
|
|
99
|
+
assert.equal(isApiRequestAllowed(alternateLoopback), false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("allows only user-initiated session export document navigations from a PWA", async () => {
|
|
103
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
104
|
+
const navigationHeaders = {
|
|
105
|
+
host: "127.0.0.1:30141",
|
|
106
|
+
"sec-fetch-site": "cross-site",
|
|
107
|
+
"sec-fetch-mode": "navigate",
|
|
108
|
+
"sec-fetch-dest": "document",
|
|
109
|
+
"sec-fetch-user": "?1",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
assert.equal(isApiRequestAllowed(new Request(
|
|
113
|
+
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
|
114
|
+
{ headers: navigationHeaders },
|
|
115
|
+
)), true);
|
|
116
|
+
assert.equal(isApiRequestAllowed(new Request(
|
|
117
|
+
"http://127.0.0.1:30141/api/sessions",
|
|
118
|
+
{ headers: navigationHeaders },
|
|
119
|
+
)), false);
|
|
120
|
+
assert.equal(isApiRequestAllowed(new Request(
|
|
121
|
+
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
|
122
|
+
{ headers: { ...navigationHeaders, "sec-fetch-dest": "empty" } },
|
|
123
|
+
)), false);
|
|
124
|
+
assert.equal(isApiRequestAllowed(new Request(
|
|
125
|
+
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
|
126
|
+
{
|
|
127
|
+
headers: {
|
|
128
|
+
...navigationHeaders,
|
|
129
|
+
"sec-fetch-user": "",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
)), false);
|
|
133
|
+
assert.equal(isApiRequestAllowed(new Request(
|
|
134
|
+
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
|
135
|
+
{ method: "POST", headers: navigationHeaders },
|
|
136
|
+
)), false);
|
|
137
|
+
assert.equal(isApiRequestAllowed(new Request(
|
|
138
|
+
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
|
|
139
|
+
{ headers: { ...navigationHeaders, host: "attacker.example:30141" } },
|
|
140
|
+
)), false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("rejects an origin that does not match the external request host", async () => {
|
|
144
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
145
|
+
const request = new Request("http://localhost:30141/api/test", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: {
|
|
148
|
+
host: "192.168.32.7:30141",
|
|
149
|
+
origin: "http://attacker.example",
|
|
150
|
+
"sec-fetch-site": "same-site",
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
assert.equal(isApiRequestAllowed(request), false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("rejects DNS rebinding even when browser headers say same-origin", async () => {
|
|
157
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
158
|
+
const request = new Request("http://localhost:30141/api/skills/install", {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: {
|
|
161
|
+
host: "attacker.example:30141",
|
|
162
|
+
origin: "http://attacker.example:30141",
|
|
163
|
+
"sec-fetch-site": "same-origin",
|
|
164
|
+
"content-type": "application/json",
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
assert.equal(isApiRequestAllowed(request), false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("rejects missing, malformed, and unconfigured Host headers", async () => {
|
|
171
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
172
|
+
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test")), false);
|
|
173
|
+
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
|
|
174
|
+
headers: { host: "localhost@attacker.example:30141" },
|
|
175
|
+
})), false);
|
|
176
|
+
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
|
|
177
|
+
headers: { host: "pi-web.internal:30141" },
|
|
178
|
+
})), false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("allows same-origin requests when Chromium strips the port from Origin", async () => {
|
|
182
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
183
|
+
// Loopback IP with the port stripped from Origin (Chromium 150+ behavior).
|
|
184
|
+
const loopback = new Request("http://127.0.0.1:30141/api/agent/running", {
|
|
185
|
+
headers: {
|
|
186
|
+
host: "127.0.0.1:30141",
|
|
187
|
+
origin: "http://127.0.0.1",
|
|
188
|
+
"sec-fetch-site": "same-origin",
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
assert.equal(isApiRequestAllowed(loopback), true);
|
|
192
|
+
|
|
193
|
+
// LAN IP with the port stripped from Origin.
|
|
194
|
+
const lan = new Request("http://192.168.32.7:30141/api/test", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: {
|
|
197
|
+
host: "192.168.32.7:30141",
|
|
198
|
+
origin: "http://192.168.32.7",
|
|
199
|
+
"sec-fetch-site": "same-origin",
|
|
200
|
+
"content-type": "application/json",
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
assert.equal(isApiRequestAllowed(lan), true);
|
|
204
|
+
|
|
205
|
+
// Loopback name with the port stripped from Origin.
|
|
206
|
+
const named = new Request("http://localhost:30141/api/test", {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: {
|
|
209
|
+
host: "localhost:30141",
|
|
210
|
+
origin: "http://localhost",
|
|
211
|
+
"sec-fetch-site": "same-origin",
|
|
212
|
+
"content-type": "application/json",
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
assert.equal(isApiRequestAllowed(named), true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("still rejects when Origin hostname differs from Host even with port stripped", async () => {
|
|
219
|
+
const { isApiRequestAllowed } = await loadSubject();
|
|
220
|
+
// Cross-loopback-name origin should not slip through just because both
|
|
221
|
+
// sides happen to be loopback addresses.
|
|
222
|
+
const crossName = new Request("http://localhost:30141/api/test", {
|
|
223
|
+
headers: {
|
|
224
|
+
host: "localhost:30141",
|
|
225
|
+
origin: "http://127.0.0.1",
|
|
226
|
+
"sec-fetch-site": "same-origin",
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
assert.equal(isApiRequestAllowed(crossName), false);
|
|
230
|
+
|
|
231
|
+
// Attacker host matching its own Origin must still be blocked by the
|
|
232
|
+
// Host allowlist, not the Origin comparison.
|
|
233
|
+
const dnsRebind = new Request("http://localhost:30141/api/skills/install", {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: {
|
|
236
|
+
host: "attacker.example:30141",
|
|
237
|
+
origin: "http://attacker.example",
|
|
238
|
+
"sec-fetch-site": "same-origin",
|
|
239
|
+
"content-type": "application/json",
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
assert.equal(isApiRequestAllowed(dnsRebind), false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("recognizes JSON request content types", async () => {
|
|
246
|
+
const { hasJsonContentType } = await loadSubject();
|
|
247
|
+
assert.equal(hasJsonContentType(new Request("http://localhost", {
|
|
248
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
249
|
+
})), true);
|
|
250
|
+
assert.equal(hasJsonContentType(new Request("http://localhost", {
|
|
251
|
+
headers: { "content-type": "application/problem+json" },
|
|
252
|
+
})), true);
|
|
253
|
+
assert.equal(hasJsonContentType(new Request("http://localhost", {
|
|
254
|
+
headers: { "content-type": "text/plain" },
|
|
255
|
+
})), false);
|
|
256
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
|
|
3
|
+
function normalizeHostname(value: string): string {
|
|
4
|
+
const unbracketed = value.startsWith("[") && value.endsWith("]")
|
|
5
|
+
? value.slice(1, -1)
|
|
6
|
+
: value;
|
|
7
|
+
return unbracketed.toLowerCase().replace(/\.$/, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function hostnameFromAuthority(value: string): string | null {
|
|
11
|
+
if (!value || /[\s/@\\]/.test(value)) return null;
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(`http://${value}`);
|
|
14
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return normalizeHostname(parsed.hostname);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeConfiguredHostname(value: string | undefined): string | null {
|
|
24
|
+
const trimmed = value?.trim();
|
|
25
|
+
if (!trimmed) return null;
|
|
26
|
+
return isIP(trimmed) ? normalizeHostname(trimmed) : hostnameFromAuthority(trimmed);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
30
|
+
return hostname === "localhost" || hostname.endsWith(".localhost");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function configuredHostnamesFromEnvironment(): string[] {
|
|
34
|
+
return [
|
|
35
|
+
process.env.PI_WEB_HOSTNAME,
|
|
36
|
+
...(process.env.PI_WEB_ALLOWED_HOSTS?.split(",") ?? []),
|
|
37
|
+
].filter((value): value is string => Boolean(value?.trim()));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canonicalOrigin(value: string): string | null {
|
|
41
|
+
try {
|
|
42
|
+
return new URL(value).origin;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getRequestOrigin(request: Request): string | null {
|
|
49
|
+
const requestUrl = new URL(request.url);
|
|
50
|
+
const host = request.headers.get("host");
|
|
51
|
+
return host ? canonicalOrigin(`${requestUrl.protocol}//${host}`) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isUserInitiatedSessionExportNavigation(request: Request): boolean {
|
|
55
|
+
if (
|
|
56
|
+
request.method !== "GET"
|
|
57
|
+
|| request.headers.get("sec-fetch-mode") !== "navigate"
|
|
58
|
+
|| request.headers.get("sec-fetch-dest") !== "document"
|
|
59
|
+
|| request.headers.get("sec-fetch-user") !== "?1"
|
|
60
|
+
) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return /^\/api\/sessions\/[^/]+\/export$/.test(new URL(request.url).pathname);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Only trust local names, IP literals, or the hostname explicitly selected by
|
|
73
|
+
* the operator. IP literals preserve LAN access but cannot be DNS-rebound
|
|
74
|
+
* because the browser keeps the literal address in the Host header.
|
|
75
|
+
*/
|
|
76
|
+
export function isApiRequestHostAllowed(
|
|
77
|
+
request: Request,
|
|
78
|
+
configuredHostnames = configuredHostnamesFromEnvironment(),
|
|
79
|
+
): boolean {
|
|
80
|
+
const host = request.headers.get("host");
|
|
81
|
+
const hostname = host ? hostnameFromAuthority(host) : null;
|
|
82
|
+
if (!hostname) return false;
|
|
83
|
+
if (isLoopbackHostname(hostname) || isIP(hostname)) return true;
|
|
84
|
+
|
|
85
|
+
return configuredHostnames.some(
|
|
86
|
+
(configured) => normalizeConfiguredHostname(configured) === hostname,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function originHostname(value: string): string | null {
|
|
91
|
+
try {
|
|
92
|
+
return new URL(value).hostname;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Reject browser cross-site API requests while preserving non-browser clients. */
|
|
99
|
+
export function isApiRequestOriginAllowed(request: Request): boolean {
|
|
100
|
+
const origin = request.headers.get("origin");
|
|
101
|
+
const fetchSite = request.headers.get("sec-fetch-site");
|
|
102
|
+
if (fetchSite === "cross-site") return false;
|
|
103
|
+
if (!origin) return true;
|
|
104
|
+
|
|
105
|
+
// Chromium 150+ strips the port from the Origin header for same-origin
|
|
106
|
+
// requests on non-default ports. Strict canonical-origin comparison would
|
|
107
|
+
// therefore reject those legitimate requests ("http://127.0.0.1:30141"
|
|
108
|
+
// vs. the browser-sent "http://127.0.0.1"). The Host header is the
|
|
109
|
+
// authoritative source for where the request actually went, so accept any
|
|
110
|
+
// Origin whose hostname matches it. Hostnames are case-insensitive, which
|
|
111
|
+
// the URL constructor already handles for us.
|
|
112
|
+
const requestOrigin = getRequestOrigin(request);
|
|
113
|
+
if (!requestOrigin) return false;
|
|
114
|
+
const originHost = originHostname(origin);
|
|
115
|
+
const requestHost = originHostname(requestOrigin);
|
|
116
|
+
return originHost !== null && originHost === requestHost;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function shouldCheckApiRequestOrigin(request: Request): boolean {
|
|
120
|
+
return request.headers.has("origin") || request.headers.has("sec-fetch-site");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isApiRequestAllowed(
|
|
124
|
+
request: Request,
|
|
125
|
+
configuredHostnames = configuredHostnamesFromEnvironment(),
|
|
126
|
+
): boolean {
|
|
127
|
+
if (!isApiRequestHostAllowed(request, configuredHostnames)) return false;
|
|
128
|
+
if (isUserInitiatedSessionExportNavigation(request)) return true;
|
|
129
|
+
return !shouldCheckApiRequestOrigin(request) || isApiRequestOriginAllowed(request);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function hasJsonContentType(request: Request): boolean {
|
|
133
|
+
const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
134
|
+
return mediaType === "application/json"
|
|
135
|
+
|| Boolean(mediaType?.startsWith("application/") && mediaType.endsWith("+json"));
|
|
136
|
+
}
|