shadow-claw 1.24.0 → 1.24.1

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.
Files changed (67) hide show
  1. package/dist/public/AGENTS.md +7 -0
  2. package/dist/public/agent.worker.js +49 -49
  3. package/dist/public/backup-controller-CvzDOq4T.js +1 -0
  4. package/dist/public/{configurePeerJs-CxpZeDJJ.js → configurePeerJs-UfQ6HUlv.js} +1 -1
  5. package/dist/public/docs/example/article/index.html +1 -1
  6. package/dist/public/docs/publishing/index.html +1 -1
  7. package/dist/public/docs/skill-creator/index.html +1 -1
  8. package/dist/public/docs/subsystems/control-plane.md +11 -0
  9. package/dist/public/{downloadGroupFile-By0GR6eV.js → downloadGroupFile-9niHBj9n.js} +1 -1
  10. package/dist/public/{e2e-bridge-IGWsRl4i.js → e2e-bridge-D28tOggc.js} +1 -1
  11. package/dist/public/{file-viewer-D0eflgt6.js → file-viewer-BVg3B_JH.js} +1 -1
  12. package/dist/public/index.html +1 -1
  13. package/dist/public/index.js +1 -1
  14. package/dist/public/initControlPlane-DKNht5ph.js +1 -0
  15. package/dist/public/main/index.html +1 -1
  16. package/dist/public/main/memory/index.html +1 -1
  17. package/dist/public/{markdown-BnpKxSxl.js → markdown-SiFq0PTy.js} +1 -1
  18. package/dist/public/{orchestrator-nJj59RqD.js → orchestrator-8w9-exfN.js} +1 -1
  19. package/dist/public/{orchestrator-3h9xxHUm.js → orchestrator-tHWrqi_F.js} +5 -5
  20. package/dist/public/{peerjs-BP4laMXj.js → peerjs-D8hck8Ih.js} +1 -1
  21. package/dist/public/push-client-D0lkwrK0.js +1 -0
  22. package/dist/public/service-worker/fetch-proxy.js +1 -1
  23. package/dist/public/service-worker.js +1 -1
  24. package/dist/public/{shadow-claw-CbwETBWf.js → shadow-claw-Cs8wbk3d.js} +2 -2
  25. package/dist/public/{shadow-claw-channels-BDWG5Qa3.js → shadow-claw-channels-mY-JMvib.js} +1 -1
  26. package/dist/public/{shadow-claw-chat-Crv0DTCY.js → shadow-claw-chat-DGPKpmU5.js} +1 -1
  27. package/dist/public/{shadow-claw-control-plane-CX1IGbh9.js → shadow-claw-control-plane-B1F0jnPk.js} +2 -2
  28. package/dist/public/{shadow-claw-conversations-_9qAaRXk.js → shadow-claw-conversations-0Q-VC_7N.js} +1 -1
  29. package/dist/public/{shadow-claw-file-viewer-Blf6yGVD.js → shadow-claw-file-viewer-AZfG4D-u.js} +1 -1
  30. package/dist/public/{shadow-claw-files-CYySBDCl.js → shadow-claw-files-Bx4v0Ggp.js} +1 -1
  31. package/dist/public/{shadow-claw-git-v8S0JjuR.js → shadow-claw-git-DD7iQRxq.js} +2 -2
  32. package/dist/public/{shadow-claw-llm-GWgoVfhI.js → shadow-claw-llm-CfeDBZKB.js} +1 -1
  33. package/dist/public/{shadow-claw-networking-qHbP95z9.js → shadow-claw-networking-e0EarDVs.js} +1 -1
  34. package/dist/public/{shadow-claw-notifications-YyHOK2Fr.js → shadow-claw-notifications-BkxuDEQI.js} +3 -3
  35. package/dist/public/{shadow-claw-pages-DFZHhzFZ.js → shadow-claw-pages-BWSE91I1.js} +1 -1
  36. package/dist/public/{shadow-claw-settings-DhxrKIfA.js → shadow-claw-settings-D6lPBn6m.js} +2 -2
  37. package/dist/public/{shadow-claw-storage-BFynbP0j.js → shadow-claw-storage-Crs6LY15.js} +1 -1
  38. package/dist/public/{shadow-claw-task-server-3BIeb5oZ.js → shadow-claw-task-server-CKm_Ennl.js} +1 -1
  39. package/dist/public/{shadow-claw-tasks-Caxc9atL.js → shadow-claw-tasks-Czx6Ucf5.js} +1 -1
  40. package/dist/public/{shadow-claw-terminal-Beb5qF-U.js → shadow-claw-terminal-Dmtr9KY1.js} +1 -1
  41. package/dist/public/{shadow-claw-tools-BfJx3A9W.js → shadow-claw-tools-t3Qq4TDO.js} +1 -1
  42. package/dist/public/{shadow-claw-webvm-BCS8ykeP.js → shadow-claw-webvm-CIuSywD_.js} +1 -1
  43. package/dist/public/{syncWebMcpRegistration-B_M2fCjH.js → syncWebMcpRegistration-DzC80wVG.js} +1 -1
  44. package/dist/server.js +3 -3
  45. package/package.json +1 -1
  46. package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +3 -0
  47. package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.ts +43 -9
  48. package/src/core/backup-controller.ts +33 -4
  49. package/src/core/control-plane-client.test.ts +9 -2
  50. package/src/core/control-plane-client.ts +18 -2
  51. package/src/core/orchestrator/utils/operations/task.ts +46 -15
  52. package/src/server/app.ts +2 -0
  53. package/src/server/control-plane.test.ts +36 -0
  54. package/src/server/control-plane.ts +39 -13
  55. package/src/server/middleware/pna.test.ts +15 -3
  56. package/src/server/middleware/pna.ts +6 -2
  57. package/src/server/routes/backup.test.ts +14 -0
  58. package/src/server/routes/backup.ts +44 -15
  59. package/src/server/server.ts +2 -0
  60. package/src/service-worker/fetch-proxy-rules.test.ts +41 -0
  61. package/src/service-worker/fetch-proxy-rules.ts +15 -8
  62. package/src/service-worker/fetch-proxy.ts +18 -2
  63. package/src/subsystems/notifications/push-client.test.ts +5 -1
  64. package/src/subsystems/notifications/push-client.ts +50 -12
  65. package/dist/public/backup-controller-C_s9in2t.js +0 -1
  66. package/dist/public/initControlPlane-C9jpnWev.js +0 -1
  67. package/dist/public/push-client-CrjEXK3U.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadow-claw",
3
- "version": "1.24.0",
3
+ "version": "1.24.1",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0",
6
6
  "description": "Browser-native personal AI assistant.",
@@ -11,6 +11,9 @@ jest.unstable_mockModule(
11
11
  getPushUrl: jest
12
12
  .fn<any>()
13
13
  .mockImplementation((path: string) => Promise.resolve(path)),
14
+ getPushFetchOptions: jest
15
+ .fn<any>()
16
+ .mockImplementation((_url: string, opts?: any) => opts || {}),
14
17
  }),
15
18
  );
16
19
 
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getCurrentSubscription,
3
+ getPushFetchOptions,
3
4
  getPushUrl,
4
5
  subscribeToPush,
5
6
  unsubscribeFromPush,
@@ -186,9 +187,12 @@ export class ShadowClawNotifications extends ShadowClawElement {
186
187
 
187
188
  try {
188
189
  const url = await getPushUrl(`/push/subscription/${this._selectedId}`);
189
- const res = await fetch(url, {
190
- method: "DELETE",
191
- });
190
+ const res = await fetch(
191
+ url,
192
+ getPushFetchOptions(url, {
193
+ method: "DELETE",
194
+ }),
195
+ );
192
196
 
193
197
  if (res.ok) {
194
198
  showSuccess("Subscription deleted.");
@@ -237,11 +241,14 @@ export class ShadowClawNotifications extends ShadowClawElement {
237
241
 
238
242
  try {
239
243
  const url = await getPushUrl("/push/send");
240
- const res = await fetch(url, {
241
- method: "POST",
242
- headers: { "Content-Type": "application/json" },
243
- body: JSON.stringify({ endpoint: sub.endpoint, payload }),
244
- });
244
+ const res = await fetch(
245
+ url,
246
+ getPushFetchOptions(url, {
247
+ method: "POST",
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({ endpoint: sub.endpoint, payload }),
250
+ }),
251
+ );
245
252
 
246
253
  if (res.ok) {
247
254
  showSuccess("Notification sent!");
@@ -303,10 +310,37 @@ export class ShadowClawNotifications extends ShadowClawElement {
303
310
  }
304
311
 
305
312
  try {
313
+ const isStaticHost =
314
+ typeof location !== "undefined" &&
315
+ (location.protocol === "file:" ||
316
+ location.hostname.endsWith(".github.io") ||
317
+ location.hostname.endsWith(".pages.dev"));
318
+
319
+ const db = await getDb();
320
+ const proxyUrl =
321
+ (await getConfig(db, CONFIG_KEYS.PUSH_PROXY_URL)) ||
322
+ (typeof localStorage !== "undefined"
323
+ ? localStorage.getItem(CONFIG_KEYS.PUSH_PROXY_URL)
324
+ : null);
325
+
326
+ if (isStaticHost && !proxyUrl) {
327
+ this._subscriptions = [];
328
+ this._backendAvailable = false;
329
+ this._selectedId = null;
330
+ this.renderSubscriptionList();
331
+ this.updateActionButtons();
332
+ return;
333
+ }
334
+
306
335
  const url = await getPushUrl("/push/subscriptions");
307
- const res = await fetch(url);
336
+ const res = await fetch(url, getPushFetchOptions(url));
308
337
 
309
338
  if (!res.ok) {
339
+ this._subscriptions = [];
340
+ this._backendAvailable = false;
341
+ this._selectedId = null;
342
+ this.renderSubscriptionList();
343
+ this.updateActionButtons();
310
344
  return;
311
345
  }
312
346
 
@@ -29,6 +29,25 @@ export interface BackupInitiateResult {
29
29
  totalBytes: number;
30
30
  }
31
31
 
32
+ function getTargetAddressSpace(urlStr: string): string | undefined {
33
+ try {
34
+ const u = new URL(urlStr);
35
+ const host = u.hostname.toLowerCase();
36
+ if (
37
+ host === "localhost" ||
38
+ host === "127.0.0.1" ||
39
+ host === "::1" ||
40
+ host === "[::1]"
41
+ ) {
42
+ return "loopback";
43
+ }
44
+ if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host)) {
45
+ return "private";
46
+ }
47
+ } catch {}
48
+ return undefined;
49
+ }
50
+
32
51
  export class BackupController {
33
52
  private _clientId: string;
34
53
  private _token?: string;
@@ -80,7 +99,7 @@ export class BackupController {
80
99
  }
81
100
 
82
101
  const uploadUrl = `${this._serverBaseUrl}/api/backup/upload`;
83
- const res = await fetch(uploadUrl, {
102
+ const uploadOpts: any = {
84
103
  method: "POST",
85
104
  headers,
86
105
  body: JSON.stringify({
@@ -90,7 +109,12 @@ export class BackupController {
90
109
  content: contentStr,
91
110
  encoding,
92
111
  }),
93
- });
112
+ };
113
+ const uploadAddressSpace = getTargetAddressSpace(uploadUrl);
114
+ if (uploadAddressSpace) {
115
+ uploadOpts.targetAddressSpace = uploadAddressSpace;
116
+ }
117
+ const res = await fetch(uploadUrl, uploadOpts);
94
118
 
95
119
  if (!res.ok) {
96
120
  const errorText = await res.text().catch(() => res.statusText);
@@ -109,7 +133,7 @@ export class BackupController {
109
133
 
110
134
  // Finalize backup
111
135
  const completeUrl = `${this._serverBaseUrl}/api/backup/complete`;
112
- const completeRes = await fetch(completeUrl, {
136
+ const completeOpts: any = {
113
137
  method: "POST",
114
138
  headers,
115
139
  body: JSON.stringify({
@@ -118,7 +142,12 @@ export class BackupController {
118
142
  fileCount: uploadedCount,
119
143
  totalBytes,
120
144
  }),
121
- });
145
+ };
146
+ const completeAddressSpace = getTargetAddressSpace(completeUrl);
147
+ if (completeAddressSpace) {
148
+ completeOpts.targetAddressSpace = completeAddressSpace;
149
+ }
150
+ const completeRes = await fetch(completeUrl, completeOpts);
122
151
 
123
152
  if (!completeRes.ok) {
124
153
  const errorText = await completeRes
@@ -97,7 +97,12 @@ class MockEventSource {
97
97
 
98
98
  describe("control-plane-client", () => {
99
99
  let mockFetch: jest.Mock<any>;
100
- let postedRequests: Array<{ url: string; body: any; headers: any }>;
100
+ let postedRequests: Array<{
101
+ url: string;
102
+ body: any;
103
+ headers: any;
104
+ targetAddressSpace?: string;
105
+ }>;
101
106
 
102
107
  beforeEach(() => {
103
108
  MockClientWebSocket.instances = [];
@@ -108,6 +113,7 @@ describe("control-plane-client", () => {
108
113
  url,
109
114
  body: JSON.parse(opts.body),
110
115
  headers: opts.headers,
116
+ targetAddressSpace: opts.targetAddressSpace,
111
117
  });
112
118
  return { ok: true, json: async () => ({ status: "received" }) };
113
119
  }) as any;
@@ -119,7 +125,7 @@ describe("control-plane-client", () => {
119
125
  });
120
126
 
121
127
  describe("SSE Transport (Default)", () => {
122
- it("connects via EventSource and sends registration over HTTP POST", async () => {
128
+ it("connects via EventSource and sends registration over HTTP POST with targetAddressSpace", async () => {
123
129
  const client = new ControlPlaneClient({
124
130
  clientId: "sse-client-1",
125
131
  deviceLabel: "SSE Device",
@@ -145,6 +151,7 @@ describe("control-plane-client", () => {
145
151
  expect(postedRequests[0].body.type).toBe("client:register");
146
152
  expect(postedRequests[0].body.payload.clientId).toBe("sse-client-1");
147
153
  expect(postedRequests[0].body.payload.transport).toBe("sse");
154
+ expect(postedRequests[0].targetAddressSpace).toBe("loopback");
148
155
 
149
156
  client.disconnect();
150
157
  expect(client.getState()).toBe("disconnected");
@@ -353,11 +353,27 @@ export class ControlPlaneClient {
353
353
  headers["Authorization"] = `Bearer ${this._token}`;
354
354
  }
355
355
 
356
- this._fetchFn(messagesUrl.toString(), {
356
+ const fetchOptions: any = {
357
357
  method: "POST",
358
358
  headers,
359
359
  body: JSON.stringify(msg),
360
- }).catch((err) => {
360
+ };
361
+
362
+ const host = messagesUrl.hostname.toLowerCase();
363
+ const isLoopback =
364
+ host === "localhost" ||
365
+ host === "127.0.0.1" ||
366
+ host === "::1" ||
367
+ host === "[::1]";
368
+ const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host);
369
+
370
+ if (isLoopback) {
371
+ fetchOptions.targetAddressSpace = "loopback";
372
+ } else if (isPrivate) {
373
+ fetchOptions.targetAddressSpace = "private";
374
+ }
375
+
376
+ this._fetchFn(messagesUrl.toString(), fetchOptions).catch((err) => {
361
377
  console.warn(
362
378
  "[ControlPlaneClient] Failed to send message via HTTP POST:",
363
379
  err,
@@ -4,6 +4,32 @@ import { showToast } from "../../../../ui/toast.js";
4
4
  import type { Task } from "../../../../db/types.js";
5
5
  import type { OrchestratorState } from "../../orchestrator-state.js";
6
6
 
7
+ function getTaskFetchOptions(
8
+ url: string,
9
+ baseOptions: RequestInit = {},
10
+ ): RequestInit {
11
+ const opts: any = { ...baseOptions };
12
+ try {
13
+ const locOrigin =
14
+ typeof location !== "undefined"
15
+ ? location.origin
16
+ : "http://127.0.0.1:8888";
17
+ const u = new URL(url, locOrigin);
18
+ const host = u.hostname.toLowerCase();
19
+ if (
20
+ host === "localhost" ||
21
+ host === "127.0.0.1" ||
22
+ host === "::1" ||
23
+ host === "[::1]"
24
+ ) {
25
+ opts.targetAddressSpace = "loopback";
26
+ } else if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host)) {
27
+ opts.targetAddressSpace = "private";
28
+ }
29
+ } catch (_) {}
30
+ return opts;
31
+ }
32
+
7
33
  export async function syncTaskToServer(
8
34
  state: Pick<OrchestratorState, "taskServerUrl" | "taskServerEnabled">,
9
35
  task: Task,
@@ -20,18 +46,22 @@ export async function syncTaskToServer(
20
46
  }
21
47
  try {
22
48
  const base = state.taskServerUrl.replace(/\/$/, "");
23
- const res = await fetch(`${base}/tasks`, {
24
- body: JSON.stringify(
25
- subscriberId
26
- ? {
27
- ...task,
28
- subscriberId,
29
- }
30
- : task,
31
- ),
32
- headers: { "Content-Type": "application/json" },
33
- method: "POST",
34
- });
49
+ const url = `${base}/tasks`;
50
+ const res = await fetch(
51
+ url,
52
+ getTaskFetchOptions(url, {
53
+ body: JSON.stringify(
54
+ subscriberId
55
+ ? {
56
+ ...task,
57
+ subscriberId,
58
+ }
59
+ : task,
60
+ ),
61
+ headers: { "Content-Type": "application/json" },
62
+ method: "POST",
63
+ }),
64
+ );
35
65
  if (!res.ok) {
36
66
  console.error("Server rejected task sync:", res.status);
37
67
  return false;
@@ -58,11 +88,12 @@ export async function deleteTaskFromServer(
58
88
  const suffix = subscriberId
59
89
  ? `?subscriberId=${encodeURIComponent(subscriberId)}`
60
90
  : "";
91
+ const url = `${base}/tasks/${encodeURIComponent(id)}${suffix}`;
61
92
  const res = await fetch(
62
- `${base}/tasks/${encodeURIComponent(id)}${suffix}`,
63
- {
93
+ url,
94
+ getTaskFetchOptions(url, {
64
95
  method: "DELETE",
65
- },
96
+ }),
66
97
  );
67
98
  if (!res.ok) {
68
99
  console.error("Server rejected task deletion:", res.status);
package/src/server/app.ts CHANGED
@@ -96,6 +96,8 @@ export function createApp(config: ServerConfig): {
96
96
  registerBackupRoutes(app, {
97
97
  backupsDir: path.resolve(config.databaseDir, "..", ".cache", "backups"),
98
98
  token: config.controlToken,
99
+ allowedOrigins: config.allowedOrigins,
100
+ corsMode: config.corsMode,
99
101
  });
100
102
 
101
103
  // ---------------- CSP REPORT ROUTES ----------------
@@ -136,6 +136,19 @@ describe("control-plane", () => {
136
136
  expect(ws.readyState).toBe(WebSocket.OPEN);
137
137
  ws.close();
138
138
  });
139
+
140
+ it("accepts GitHub Pages origin connection without token", async () => {
141
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/control`, {
142
+ headers: { Origin: "https://xt-ml.github.io" },
143
+ });
144
+ await new Promise<void>((resolve, reject) => {
145
+ ws.on("open", () => resolve());
146
+ ws.on("error", reject);
147
+ });
148
+
149
+ expect(ws.readyState).toBe(WebSocket.OPEN);
150
+ ws.close();
151
+ });
139
152
  });
140
153
 
141
154
  describe("Client registration and heartbeat", () => {
@@ -353,6 +366,29 @@ describe("control-plane", () => {
353
366
  expect(res.data.reply?.type).toBe("server:registered");
354
367
  });
355
368
 
369
+ it("POST /api/control/messages processes message from GitHub Pages origin without token", async () => {
370
+ const res = await makeHttpRequest({
371
+ port,
372
+ path: "/api/control/messages",
373
+ method: "POST",
374
+ headers: { Origin: "https://xt-ml.github.io" },
375
+ body: {
376
+ id: "http-msg-gh-1",
377
+ type: "client:register",
378
+ payload: {
379
+ clientId: "http-client-gh",
380
+ deviceLabel: "GitHub Pages Client",
381
+ capabilities: ["opfs"],
382
+ version: "1.0.0",
383
+ },
384
+ },
385
+ });
386
+
387
+ expect(res.status).toBe(200);
388
+ expect(res.data.status).toBe("received");
389
+ expect(res.data.reply?.type).toBe("server:registered");
390
+ });
391
+
356
392
  it("dispatches command to SSE client and resolves when client POSTs result", async () => {
357
393
  // 1. Establish SSE connection
358
394
  let sseReq: http.ClientRequest;
@@ -34,6 +34,8 @@ export interface ControlPlaneOptions {
34
34
  token?: string;
35
35
  heartbeatTimeoutMs?: number;
36
36
  verbose?: boolean;
37
+ allowedOrigins?: Set<string>;
38
+ corsMode?: "localhost" | "private" | "all";
37
39
  }
38
40
 
39
41
  export interface PendingCommand {
@@ -115,34 +117,58 @@ export function createControlPlane(options: ControlPlaneOptions): ControlPlane {
115
117
  const origin = req.headers.origin;
116
118
  const referer = req.headers.referer;
117
119
 
118
- if (origin) {
120
+ const isTrustedUrl = (urlStr: string): boolean => {
119
121
  try {
120
- const originUrl = new URL(origin);
121
- if (originUrl.host === host) {
122
+ const parsed = new URL(urlStr);
123
+ if (parsed.host === host) {
122
124
  return true;
123
125
  }
126
+
127
+ const hostname = parsed.hostname.toLowerCase();
124
128
  if (
125
- originUrl.hostname === "127.0.0.1" ||
126
- originUrl.hostname === "localhost"
129
+ hostname === "127.0.0.1" ||
130
+ hostname === "localhost" ||
131
+ hostname === "::1" ||
132
+ hostname === "[::1]"
127
133
  ) {
128
134
  return true;
129
135
  }
130
- } catch (_) {}
131
- }
132
136
 
133
- if (referer) {
134
- try {
135
- const refererUrl = new URL(referer);
136
- if (refererUrl.host === host) {
137
+ if (
138
+ hostname === "github.com" ||
139
+ hostname.endsWith(".github.io") ||
140
+ hostname.endsWith(".pages.dev")
141
+ ) {
142
+ return true;
143
+ }
144
+
145
+ if (options.corsMode === "all") {
146
+ return true;
147
+ }
148
+
149
+ if (
150
+ options.allowedOrigins &&
151
+ options.allowedOrigins.has(parsed.origin)
152
+ ) {
137
153
  return true;
138
154
  }
155
+
139
156
  if (
140
- refererUrl.hostname === "127.0.0.1" ||
141
- refererUrl.hostname === "localhost"
157
+ options.corsMode === "private" &&
158
+ /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname)
142
159
  ) {
143
160
  return true;
144
161
  }
145
162
  } catch (_) {}
163
+ return false;
164
+ };
165
+
166
+ if (origin && isTrustedUrl(origin)) {
167
+ return true;
168
+ }
169
+
170
+ if (referer && isTrustedUrl(referer)) {
171
+ return true;
146
172
  }
147
173
 
148
174
  const secFetchSite = req.headers["sec-fetch-site"];
@@ -15,7 +15,7 @@ describe("pna-middleware", () => {
15
15
  next = jest.fn();
16
16
  });
17
17
 
18
- it("sets Access-Control-Allow-Private-Network header if requested", () => {
18
+ it("sets Access-Control-Allow-Private-Network header if requested explicitly", () => {
19
19
  req.headers["access-control-request-private-network"] = "true";
20
20
  const middleware = createPnaMiddleware(logger, false);
21
21
  middleware(req, res, next);
@@ -27,7 +27,19 @@ describe("pna-middleware", () => {
27
27
  expect(next).toHaveBeenCalled();
28
28
  });
29
29
 
30
- it("does not set header if not requested", () => {
30
+ it("sets Access-Control-Allow-Private-Network header for cross-origin requests", () => {
31
+ req.headers.origin = "https://xt-ml.github.io";
32
+ const middleware = createPnaMiddleware(logger, false);
33
+ middleware(req, res, next);
34
+
35
+ expect(res.setHeader).toHaveBeenCalledWith(
36
+ "Access-Control-Allow-Private-Network",
37
+ "true",
38
+ );
39
+ expect(next).toHaveBeenCalled();
40
+ });
41
+
42
+ it("does not set header if neither requested nor origin present", () => {
31
43
  const middleware = createPnaMiddleware(logger, false);
32
44
  middleware(req, res, next);
33
45
 
@@ -35,7 +47,7 @@ describe("pna-middleware", () => {
35
47
  expect(next).toHaveBeenCalled();
36
48
  });
37
49
 
38
- it("logs if verbose is true", () => {
50
+ it("logs if verbose is true and header is explicitly requested", () => {
39
51
  req.headers["access-control-request-private-network"] = "true";
40
52
  const middleware = createPnaMiddleware(logger, true);
41
53
  middleware(req, res, next);
@@ -3,8 +3,12 @@ import type { Logger } from "../logger.js";
3
3
 
4
4
  export function createPnaMiddleware(logger: Logger, verbose: boolean) {
5
5
  return (req: Request, res: Response, next: NextFunction) => {
6
- if (req.headers["access-control-request-private-network"] === "true") {
7
- if (verbose) {
6
+ const isPnaExplicit =
7
+ req.headers["access-control-request-private-network"] === "true";
8
+ const hasOrigin = Boolean(req.headers.origin);
9
+
10
+ if (isPnaExplicit || hasOrigin) {
11
+ if (verbose && isPnaExplicit) {
8
12
  logger.log("VERBOSE", `[PNA] Allowing Private Network Access`);
9
13
  }
10
14
 
@@ -121,6 +121,20 @@ describe("backup routes", () => {
121
121
  expect(res.data.backups).toBeDefined();
122
122
  });
123
123
 
124
+ it("allows GitHub Pages origin requests without explicit token header", async () => {
125
+ const res = await makeHttpRequest({
126
+ port,
127
+ path: "/api/backup/list",
128
+ headers: {
129
+ host: `127.0.0.1:${port}`,
130
+ origin: "https://xt-ml.github.io",
131
+ },
132
+ });
133
+
134
+ expect(res.status).toBe(200);
135
+ expect(res.data.backups).toBeDefined();
136
+ });
137
+
124
138
  it("uploads a file and verifies on disk", async () => {
125
139
  const res = await makeHttpRequest({
126
140
  port,
@@ -20,6 +20,8 @@ import {
20
20
  export interface BackupRoutesOptions {
21
21
  backupsDir: string;
22
22
  token?: string;
23
+ allowedOrigins?: Set<string>;
24
+ corsMode?: "localhost" | "private" | "all";
23
25
  }
24
26
 
25
27
  function isSafeRelativePath(relPath: string): boolean {
@@ -37,39 +39,66 @@ function isSafeRelativePath(relPath: string): boolean {
37
39
  return true;
38
40
  }
39
41
 
40
- function isSameOriginBrowser(req: Request): boolean {
42
+ function isSameOriginBrowser(
43
+ req: Request,
44
+ options?: BackupRoutesOptions,
45
+ ): boolean {
41
46
  const host = req.headers.host || "127.0.0.1";
42
47
  const origin = req.headers.origin;
43
48
  const referer = req.headers.referer;
44
49
 
45
- if (origin) {
50
+ const isTrustedUrl = (urlStr: string): boolean => {
46
51
  try {
47
- const originUrl = new URL(origin);
48
- if (originUrl.host === host) {
52
+ const parsed = new URL(urlStr);
53
+ if (parsed.host === host) {
49
54
  return true;
50
55
  }
56
+
57
+ const hostname = parsed.hostname.toLowerCase();
51
58
  if (
52
- originUrl.hostname === "127.0.0.1" ||
53
- originUrl.hostname === "localhost"
59
+ hostname === "127.0.0.1" ||
60
+ hostname === "localhost" ||
61
+ hostname === "::1" ||
62
+ hostname === "[::1]"
54
63
  ) {
55
64
  return true;
56
65
  }
57
- } catch (_) {}
58
- }
59
66
 
60
- if (referer) {
61
- try {
62
- const refererUrl = new URL(referer);
63
- if (refererUrl.host === host) {
67
+ if (
68
+ hostname === "github.com" ||
69
+ hostname.endsWith(".github.io") ||
70
+ hostname.endsWith(".pages.dev")
71
+ ) {
72
+ return true;
73
+ }
74
+
75
+ if (options?.corsMode === "all") {
76
+ return true;
77
+ }
78
+
79
+ if (
80
+ options?.allowedOrigins &&
81
+ options.allowedOrigins.has(parsed.origin)
82
+ ) {
64
83
  return true;
65
84
  }
85
+
66
86
  if (
67
- refererUrl.hostname === "127.0.0.1" ||
68
- refererUrl.hostname === "localhost"
87
+ options?.corsMode === "private" &&
88
+ /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname)
69
89
  ) {
70
90
  return true;
71
91
  }
72
92
  } catch (_) {}
93
+ return false;
94
+ };
95
+
96
+ if (origin && isTrustedUrl(origin)) {
97
+ return true;
98
+ }
99
+
100
+ if (referer && isTrustedUrl(referer)) {
101
+ return true;
73
102
  }
74
103
 
75
104
  const secFetchSite = req.headers["sec-fetch-site"];
@@ -103,7 +132,7 @@ export function registerBackupRoutes(
103
132
 
104
133
  const isAuthValid = providedToken
105
134
  ? providedToken === token
106
- : isSameOriginBrowser(req);
135
+ : isSameOriginBrowser(req, options);
107
136
 
108
137
  if (!isAuthValid) {
109
138
  res.status(401).json({ error: "Unauthorized: Invalid control token" });
@@ -34,6 +34,8 @@ export async function startServer(
34
34
  app,
35
35
  token: config.controlToken,
36
36
  verbose: config.verbose,
37
+ allowedOrigins: config.allowedOrigins,
38
+ corsMode: config.corsMode,
37
39
  });
38
40
 
39
41
  let serverPeer: ServerPeer | null = null;