clay-server 4.7.0 → 4.7.1-beta.2

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 CHANGED
@@ -73,6 +73,12 @@ Clay Studio does not relay project traffic through a Clay-hosted cloud. Your cod
73
73
 
74
74
  The `d.clay.studio` hostname used for local HTTPS is a DNS-only service: no application data passes through it. See [clay-dns](clay-dns/) for the implementation and threat model.
75
75
 
76
+ ### Reverse proxies and tunnels
77
+
78
+ If your proxy rewrites the upstream `Host` header, open **Server Settings → Network → Reverse proxy & tunnels** and add your browser-facing origin, for example `https://clay.example.com`. Enter one HTTP or HTTPS origin per line, with a port when needed and no path or wildcard. Only server administrators can edit this setting in multi-user mode. Saving applies to new WebSocket connections without a restart; existing connections stay open until they reconnect, and authentication remains required. When Clay refuses a WebSocket because of its origin, the daemon log notes the refused origin (at most once a minute).
79
+
80
+ If the tunnel cannot connect yet, use a working local connection to configure it. Alternatively, stop the daemon, add `"allowedOrigins": ["https://clay.example.com"]` to the existing `~/.clay/daemon.json` (or the daemon config in your `CLAY_HOME`), preserving its other fields, and start Clay again. Direct connections and proxies that preserve `Host` normally need no additional origins. Forwarded headers are not trusted automatically.
81
+
76
82
  ## Common CLI commands
77
83
 
78
84
  ```bash
package/lib/daemon.js CHANGED
@@ -789,6 +789,10 @@ var relay = createServer({
789
789
  }
790
790
  return { ok: false, error: "Project not found" };
791
791
  },
792
+ onGetAllowedOrigins: function () { return config.allowedOrigins || []; },
793
+ onSetAllowedOrigins: function (origins) {
794
+ require("./network-origins").saveAllowedOrigins(config, origins, saveConfig);
795
+ },
792
796
  onGetDaemonConfig: function () {
793
797
  return {
794
798
  port: config.port,
@@ -0,0 +1,38 @@
1
+ // Rate-limited log hint for rejected WebSocket origins. State is fixed-size
2
+ // (one timestamp and one counter), so hostile traffic cannot grow memory or
3
+ // flood the daemon log. Only Origin and Host are logged, never cookies or tokens.
4
+ var DEFAULT_INTERVAL_MS = 60000;
5
+ var MAX_VALUE_LENGTH = 200;
6
+
7
+ function safeValue(value) {
8
+ if (typeof value !== "string") return "(none)";
9
+ var text = value.length > MAX_VALUE_LENGTH ? value.slice(0, MAX_VALUE_LENGTH) + "..." : value;
10
+ // JSON quoting escapes newlines and control characters, preventing log injection.
11
+ return JSON.stringify(text);
12
+ }
13
+
14
+ function createOriginRejectionLogger(options) {
15
+ var opts = options || {};
16
+ var intervalMs = opts.intervalMs || DEFAULT_INTERVAL_MS;
17
+ var now = opts.now || Date.now;
18
+ var log = opts.log || function (message) { console.warn(message); };
19
+ var lastLoggedAt = null;
20
+ var suppressed = 0;
21
+
22
+ return function logRejectedOrigin(origin, host) {
23
+ var current = now();
24
+ if (lastLoggedAt !== null && current - lastLoggedAt < intervalMs) {
25
+ suppressed++;
26
+ return false;
27
+ }
28
+ var message = "[network] Rejected WebSocket connection from origin " + safeValue(origin) +
29
+ " (Host " + safeValue(host) + "). If this is your reverse proxy or tunnel address, an administrator can add it in Server Settings > Network.";
30
+ if (suppressed > 0) message += " " + suppressed + " similar rejection(s) were not logged.";
31
+ lastLoggedAt = current;
32
+ suppressed = 0;
33
+ log(message);
34
+ return true;
35
+ };
36
+ }
37
+
38
+ module.exports = { createOriginRejectionLogger: createOriginRejectionLogger };
@@ -0,0 +1,90 @@
1
+ // Shared validation for configured browser origins and WebSocket handshakes.
2
+ var MAX_ORIGINS = 100;
3
+ var MAX_ORIGIN_LENGTH = 2048;
4
+
5
+ function invalid(reason) {
6
+ var error = new Error(reason);
7
+ error.reason = reason;
8
+ return error;
9
+ }
10
+
11
+ function normalizeOrigin(value) {
12
+ if (typeof value !== "string") throw invalid("must be text, for example https://clay.example.com.");
13
+ if (value.length > MAX_ORIGIN_LENGTH) throw invalid("is longer than " + MAX_ORIGIN_LENGTH + " characters.");
14
+ if (value.indexOf("*") !== -1) throw invalid("cannot contain wildcards (*). List each address separately.");
15
+ if (!/^https?:\/\//i.test(value)) throw invalid("must start with http:// or https://.");
16
+ if (!/^https?:\/\/[^\s/?#\\]+\/?$/i.test(value)) {
17
+ throw invalid("cannot include a path, query, fragment, or spaces. Use only the scheme, host, and optional port.");
18
+ }
19
+ var url;
20
+ try { url = new URL(value); } catch (e) { throw invalid("is not a valid address. Check the host name and port."); }
21
+ if (url.username || url.password) throw invalid("cannot include a username or password.");
22
+ return url.origin;
23
+ }
24
+
25
+ function normalizeAllowedOrigins(values) {
26
+ if (!Array.isArray(values)) throw invalid("Allowed origins must be a list.");
27
+ if (values.length > MAX_ORIGINS) throw invalid("Enter at most " + MAX_ORIGINS + " allowed origins.");
28
+ var normalized = [];
29
+ for (var i = 0; i < values.length; i++) {
30
+ try {
31
+ normalized.push(normalizeOrigin(values[i]));
32
+ } catch (e) {
33
+ var error = invalid("Entry " + (i + 1) + " " + e.reason);
34
+ error.index = i;
35
+ error.entryReason = e.reason;
36
+ throw error;
37
+ }
38
+ }
39
+ return Array.from(new Set(normalized));
40
+ }
41
+
42
+ // Describes stored entries for the editor without dropping invalid manual edits.
43
+ function describeConfiguredOrigins(raw) {
44
+ if (raw === undefined || raw === null) return { allowedOrigins: [], invalid: [] };
45
+ var list = Array.isArray(raw) ? raw : [raw];
46
+ var entries = [];
47
+ var problems = [];
48
+ for (var i = 0; i < list.length; i++) {
49
+ var value = list[i];
50
+ entries.push(typeof value === "string" ? value : JSON.stringify(value) || String(value));
51
+ try { normalizeOrigin(value); } catch (e) { problems.push({ index: i, reason: e.reason }); }
52
+ }
53
+ if (!Array.isArray(raw)) problems.unshift({ index: 0, reason: "is not stored as a list. Save to store it correctly." });
54
+ return { allowedOrigins: entries, invalid: problems };
55
+ }
56
+
57
+ function isAllowedOrigin(origin, host, allowedOrigins) {
58
+ // Non-browser clients may omit Origin; authentication is still required.
59
+ if (origin === undefined) return true;
60
+ try {
61
+ var normalized = normalizeOrigin(origin);
62
+ var configured = Array.isArray(allowedOrigins) ? allowedOrigins : [];
63
+ for (var i = 0; i < configured.length; i++) {
64
+ try {
65
+ if (normalizeOrigin(configured[i]) === normalized) return true;
66
+ } catch (e) { /* Invalid manual config entries never grant access. */ }
67
+ }
68
+ // Preserve Host-based access through TLS-terminating proxies. Forwarded
69
+ // headers are deliberately not trusted; rewritten hosts need an allowlist.
70
+ if (typeof host !== "string" || !host.trim()) return false;
71
+ var protocol = new URL(normalized).protocol;
72
+ return normalizeOrigin(protocol + "//" + host) === normalized;
73
+ } catch (e) {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function saveAllowedOrigins(config, origins, saveConfig) {
79
+ var normalized = normalizeAllowedOrigins(origins);
80
+ var nextConfig = Object.assign({}, config, { allowedOrigins: normalized });
81
+ saveConfig(nextConfig);
82
+ config.allowedOrigins = normalized;
83
+ }
84
+
85
+ module.exports = {
86
+ normalizeAllowedOrigins: normalizeAllowedOrigins,
87
+ describeConfiguredOrigins: describeConfiguredOrigins,
88
+ isAllowedOrigin: isAllowedOrigin,
89
+ saveAllowedOrigins: saveAllowedOrigins,
90
+ };
package/lib/public/app.js CHANGED
@@ -267,6 +267,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
267
267
 
268
268
  // --- Initialize store (single source of truth for client state) ---
269
269
  createStore({
270
+ networkSettings: { loaded: false, busy: false, confirming: false, draft: "", saved: "", status: "", errorKind: "", requestId: 0 },
270
271
  // session / routing
271
272
  basePath: basePath,
272
273
  wsPath: wsPath,
@@ -0,0 +1,41 @@
1
+ .settings-network-origins {
2
+ box-sizing: border-box;
3
+ width: 100%;
4
+ min-height: 128px;
5
+ margin-top: 12px;
6
+ padding: 12px;
7
+ resize: vertical;
8
+ border: 1px solid var(--border-subtle);
9
+ border-radius: 8px;
10
+ background: var(--bg-secondary);
11
+ color: var(--text);
12
+ font-family: var(--font-mono, monospace);
13
+ font-size: 13px;
14
+ line-height: 1.7;
15
+ }
16
+
17
+ .settings-network-origins:focus-visible {
18
+ outline: 2px solid var(--accent);
19
+ outline-offset: 2px;
20
+ }
21
+
22
+ .settings-network-origins:disabled { opacity: 0.6; }
23
+
24
+ .settings-network-actions {
25
+ display: flex;
26
+ justify-content: space-between;
27
+ align-items: center;
28
+ flex-wrap: wrap;
29
+ gap: 12px;
30
+ margin-top: 12px;
31
+ }
32
+
33
+ .settings-network-error { color: var(--error, #ef6464); }
34
+
35
+ .settings-network-actions .settings-btn-sm:disabled {
36
+ opacity: 0.45;
37
+ cursor: not-allowed;
38
+ }
39
+
40
+ /* The shared confirm modal normally sits below the Settings overlay. */
41
+ #confirm-modal.confirm-above-settings { z-index: 700; }
@@ -1250,6 +1250,7 @@
1250
1250
  <option value="overview" selected>Status</option>
1251
1251
  <option value="notifications">Notifications</option>
1252
1252
  <option value="security">Security</option>
1253
+ <option value="network">Network</option>
1253
1254
  </optgroup>
1254
1255
  <optgroup label="Config">
1255
1256
  <option value="claudemd">Instructions</option>
@@ -1273,6 +1274,7 @@
1273
1274
  <button class="settings-nav-item active" data-section="overview"><span>Status</span></button>
1274
1275
  <button class="settings-nav-item" data-section="notifications"><span>Notifications</span></button>
1275
1276
  <button class="settings-nav-item" data-section="security"><span>Security</span></button>
1277
+ <button class="settings-nav-item" data-section="network"><span>Network</span></button>
1276
1278
  <div class="settings-nav-separator"></div>
1277
1279
  <div class="settings-nav-category">Config</div>
1278
1280
  <button class="settings-nav-item" data-section="claudemd"><span>Instructions</span></button>
@@ -1401,6 +1403,23 @@
1401
1403
  </div>
1402
1404
  </div>
1403
1405
  </div>
1406
+ <div class="server-settings-section" data-section="network">
1407
+ <h2>Network</h2>
1408
+ <h3>Reverse proxy &amp; tunnels</h3>
1409
+ <p class="settings-hint">Connect to Clay through an external address, even when your proxy changes the upstream host.</p>
1410
+ <form id="settings-network-form" class="settings-card">
1411
+ <div class="settings-field">
1412
+ <label class="settings-label" for="settings-network-origins">Allowed external origins</label>
1413
+ <p class="settings-hint" id="settings-network-help">Enter one address per line, including http:// or https:// and a port if needed. Do not include paths or wildcards. Leave empty if you connect directly.</p>
1414
+ <textarea id="settings-network-origins" class="settings-network-origins" rows="5" placeholder="https://clay.example.com" spellcheck="false" autocapitalize="off" autocomplete="off" aria-describedby="settings-network-help settings-network-status" disabled></textarea>
1415
+ <div class="settings-network-actions">
1416
+ <span id="settings-network-status" class="settings-hint" role="status" aria-live="polite"></span>
1417
+ <button type="submit" id="settings-network-save" class="settings-btn-sm" disabled>Save changes</button>
1418
+ </div>
1419
+ </div>
1420
+ </form>
1421
+ <p class="settings-hint">Applies to the entire server. Saved changes take effect on new connections without restarting; existing connections stay open until they reconnect. Login requirements stay unchanged.</p>
1422
+ </div>
1404
1423
  <div class="server-settings-section" data-section="notifications">
1405
1424
  <h2>Notifications</h2>
1406
1425
  <div class="settings-card">
@@ -9,6 +9,7 @@ import { initPasteModal } from './paste-modal.js';
9
9
 
10
10
  // --- Module-owned state ---
11
11
  var confirmCallback = null;
12
+ var confirmCancelCallback = null;
12
13
  var _extRequestCallbacks = {};
13
14
 
14
15
  // Queue for extension messages that arrived before WS was ready
@@ -51,6 +52,7 @@ export function initMisc() {
51
52
  var confirmCancel = document.getElementById("confirm-cancel");
52
53
 
53
54
  confirmOk.addEventListener("click", function () {
55
+ confirmCancelCallback = null;
54
56
  if (confirmCallback) confirmCallback();
55
57
  hideConfirm();
56
58
  });
@@ -259,12 +261,17 @@ export function closeImageModal() {
259
261
  if (modal) modal.classList.add("hidden");
260
262
  }
261
263
 
262
- export function showConfirm(text, onConfirm, okLabel, destructive) {
264
+ // onCancel (optional) runs when the dialog is dismissed or replaced without confirming.
265
+ export function showConfirm(text, onConfirm, okLabel, destructive, onCancel) {
263
266
  var confirmText = document.getElementById("confirm-text");
264
267
  var confirmOk = document.getElementById("confirm-ok");
265
268
  var confirmModal = document.getElementById("confirm-modal");
269
+ var replaced = confirmCancelCallback;
270
+ confirmCancelCallback = null;
271
+ if (replaced) replaced();
266
272
  confirmText.textContent = text;
267
273
  confirmCallback = onConfirm;
274
+ confirmCancelCallback = onCancel || null;
268
275
  confirmOk.textContent = okLabel || "Delete";
269
276
  confirmOk.className = "confirm-btn " + (destructive === false ? "confirm-ok" : "confirm-delete");
270
277
  confirmModal.classList.remove("hidden");
@@ -274,6 +281,9 @@ export function hideConfirm() {
274
281
  var confirmModal = document.getElementById("confirm-modal");
275
282
  confirmModal.classList.add("hidden");
276
283
  confirmCallback = null;
284
+ var cancelled = confirmCancelCallback;
285
+ confirmCancelCallback = null;
286
+ if (cancelled) cancelled();
277
287
  }
278
288
 
279
289
  export function showForceChangePinOverlay() {
@@ -0,0 +1,56 @@
1
+ // Pure helpers for the Network settings editor. Mirrors lib/network-origins.js
2
+ // closely enough to compare spellings; the server remains the authority.
3
+
4
+ export function normalizeOriginForCompare(value) {
5
+ if (typeof value !== 'string' || value.indexOf('*') !== -1 ||
6
+ !/^https?:\/\/[^\s/?#\\]+\/?$/i.test(value)) return null;
7
+ try {
8
+ var url = new URL(value);
9
+ if (url.username || url.password) return null;
10
+ return url.origin;
11
+ } catch (e) {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ // Returns trimmed non-empty entries plus the 1-based textarea line of each.
17
+ export function parseOriginDraft(draft) {
18
+ var origins = [];
19
+ var lines = [];
20
+ var rows = String(draft || '').split('\n');
21
+ for (var i = 0; i < rows.length; i++) {
22
+ var value = rows[i].trim();
23
+ if (!value) continue;
24
+ origins.push(value);
25
+ lines.push(i + 1);
26
+ }
27
+ return { origins: origins, lines: lines };
28
+ }
29
+
30
+ function normalizedSet(values) {
31
+ var set = {};
32
+ for (var i = 0; i < values.length; i++) {
33
+ var origin = normalizeOriginForCompare(values[i]);
34
+ if (origin) set[origin] = true;
35
+ }
36
+ return set;
37
+ }
38
+
39
+ // Returns the page's normalized origin when the saved list allowed it and the
40
+ // next list does not; otherwise null. Equivalent spellings count as kept.
41
+ export function removesCurrentOrigin(savedText, nextOrigins, currentOrigin) {
42
+ var current = normalizeOriginForCompare(currentOrigin);
43
+ if (!current) return null;
44
+ if (!normalizedSet(parseOriginDraft(savedText).origins)[current]) return null;
45
+ if (normalizedSet(nextOrigins)[current]) return null;
46
+ return current;
47
+ }
48
+
49
+ // Turns stored-entry problems into a status message using textarea line numbers.
50
+ export function describeInvalidEntries(invalid) {
51
+ if (!Array.isArray(invalid) || !invalid.length) return '';
52
+ var first = invalid[0];
53
+ var message = 'Line ' + (first.index + 1) + ' ' + first.reason;
54
+ if (invalid.length > 1) message += ' ' + (invalid.length - 1) + ' more line(s) also need correction.';
55
+ return message + ' Invalid lines are ignored until corrected.';
56
+ }
@@ -0,0 +1,133 @@
1
+ import { store } from './store.js';
2
+ import { showConfirm, hideConfirm } from './app-misc.js';
3
+ import { parseOriginDraft, removesCurrentOrigin, describeInvalidEntries } from './network-origin-guard.js';
4
+
5
+ // errorKind: 'validation' marks the textarea invalid; 'request' covers
6
+ // network, authorization, and disk failures that are not the input's fault.
7
+ function updateNetworkState(values) {
8
+ store.set({ networkSettings: Object.assign({}, store.get('networkSettings'), values) });
9
+ renderNetworkSettings();
10
+ }
11
+
12
+ function renderNetworkSettings() {
13
+ var state = store.get('networkSettings');
14
+ var input = document.getElementById('settings-network-origins');
15
+ var button = document.getElementById('settings-network-save');
16
+ var status = document.getElementById('settings-network-status');
17
+ if (!input || !state) return;
18
+ var locked = state.busy || state.confirming || !state.loaded;
19
+ if (input.value !== state.draft) input.value = state.draft;
20
+ input.disabled = locked;
21
+ button.disabled = locked || state.draft === state.saved;
22
+ button.textContent = state.busy && state.loaded ? 'Saving...' : 'Save changes';
23
+ status.textContent = state.status;
24
+ status.classList.toggle('settings-network-error', !!state.errorKind);
25
+ input.setAttribute('aria-invalid', state.errorKind === 'validation' ? 'true' : 'false');
26
+ }
27
+
28
+ async function requestNetworkSettings(options) {
29
+ var response = await fetch('/api/server/network', Object.assign({ credentials: 'same-origin' }, options));
30
+ var data;
31
+ try { data = await response.json(); } catch (e) { data = {}; }
32
+ if (!response.ok) {
33
+ var error = new Error(data.error || 'Could not reach network settings (HTTP ' + response.status + ').');
34
+ if (typeof data.index === 'number') error.index = data.index;
35
+ error.reason = data.reason;
36
+ throw error;
37
+ }
38
+ return data;
39
+ }
40
+
41
+ function loadedState(data, extra) {
42
+ var value = (data.allowedOrigins || []).join('\n');
43
+ var invalidMessage = describeInvalidEntries(data.invalid);
44
+ return Object.assign({ loaded: true, busy: false, draft: value, saved: value,
45
+ status: invalidMessage, errorKind: invalidMessage ? 'validation' : '' }, extra || {});
46
+ }
47
+
48
+ export async function loadNetworkSettings() {
49
+ var current = store.get('networkSettings');
50
+ if (current.busy || current.confirming || (current.loaded && current.draft !== current.saved)) return;
51
+ var requestId = current.requestId + 1;
52
+ updateNetworkState({ busy: true, loaded: false, status: 'Loading...', errorKind: '', requestId: requestId });
53
+ try {
54
+ var data = await requestNetworkSettings();
55
+ if (store.get('networkSettings').requestId !== requestId) return;
56
+ updateNetworkState(loadedState(data));
57
+ } catch (e) {
58
+ if (store.get('networkSettings').requestId !== requestId) return;
59
+ updateNetworkState({ loaded: false, busy: false, draft: '', saved: '', status: e.message, errorKind: 'request' });
60
+ }
61
+ }
62
+
63
+ async function submitOrigins(parsed) {
64
+ updateNetworkState({ busy: true, status: 'Saving...', errorKind: '' });
65
+ try {
66
+ var data = await requestNetworkSettings({
67
+ method: 'PUT',
68
+ headers: { 'Content-Type': 'application/json', 'X-Clay-Network-Settings': '1' },
69
+ body: JSON.stringify({ allowedOrigins: parsed.origins }),
70
+ });
71
+ updateNetworkState(loadedState(data, { status: 'Saved. Applies to new connections; existing connections stay open.' }));
72
+ } catch (e) {
73
+ if (typeof e.index === 'number' && e.reason) {
74
+ var line = parsed.lines[e.index] || e.index + 1;
75
+ updateNetworkState({ busy: false, status: 'Line ' + line + ' ' + e.reason, errorKind: 'validation' });
76
+ } else {
77
+ updateNetworkState({ busy: false, status: e.message, errorKind: 'request' });
78
+ }
79
+ }
80
+ }
81
+
82
+ function confirmCurrentOriginRemoval(origin, parsed) {
83
+ var modal = document.getElementById('confirm-modal');
84
+ function settle() { if (modal) modal.classList.remove('confirm-above-settings'); }
85
+ updateNetworkState({ confirming: true });
86
+ if (modal) modal.classList.add('confirm-above-settings');
87
+ showConfirm('This removes ' + origin + ', the address you are using now. Your current connection stays open, ' +
88
+ 'but the next reconnect or page reload from this address may be refused, and you may need local access to Clay to add it back.',
89
+ function () {
90
+ settle();
91
+ updateNetworkState({ confirming: false });
92
+ submitOrigins(parsed);
93
+ }, 'Remove and save', true, function () {
94
+ settle();
95
+ updateNetworkState({ confirming: false, status: 'Not saved. Your edits are still here.', errorKind: '' });
96
+ var input = document.getElementById('settings-network-origins');
97
+ if (input) input.focus();
98
+ });
99
+ // Default keyboard focus to the safe choice.
100
+ var cancel = document.getElementById('confirm-cancel');
101
+ if (cancel) cancel.focus();
102
+ }
103
+
104
+ // Escape cancels a pending removal instead of closing Settings underneath it.
105
+ function handleConfirmEscape(event) {
106
+ if (event.key !== 'Escape' || !store.get('networkSettings').confirming) return;
107
+ event.preventDefault();
108
+ event.stopImmediatePropagation();
109
+ hideConfirm();
110
+ }
111
+
112
+ export function saveNetworkSettings(event) {
113
+ if (event) event.preventDefault();
114
+ var state = store.get('networkSettings');
115
+ if (!state.loaded || state.busy || state.confirming) return;
116
+ var parsed = parseOriginDraft(state.draft);
117
+ var removed = removesCurrentOrigin(state.saved, parsed.origins, window.location.origin);
118
+ if (removed) {
119
+ confirmCurrentOriginRemoval(removed, parsed);
120
+ return;
121
+ }
122
+ return submitOrigins(parsed);
123
+ }
124
+
125
+ export function initNetworkSettings() {
126
+ var form = document.getElementById('settings-network-form');
127
+ if (!form) return;
128
+ form.addEventListener('submit', saveNetworkSettings);
129
+ window.addEventListener('keydown', handleConfirmEscape, true);
130
+ document.getElementById('settings-network-origins').addEventListener('input', function () {
131
+ updateNetworkState({ draft: this.value, status: '', errorKind: '' });
132
+ });
133
+ }
@@ -4,6 +4,7 @@ import { showToast, copyToClipboard } from './utils.js';
4
4
  import { parseEnvString, looksLikeEnv, serializeEnvValue } from './project-settings.js';
5
5
  import { checkAdminAccess, loadAdminSection } from './admin.js';
6
6
  import { closeFileViewer } from './filebrowser.js';
7
+ import { initNetworkSettings, loadNetworkSettings } from './server-network-settings.js';
7
8
 
8
9
  var ctx = null;
9
10
  var settingsEl = null;
@@ -24,6 +25,8 @@ export function initServerSettings(appCtx) {
24
25
  navItems = settingsEl.querySelectorAll(".settings-nav-item");
25
26
  sections = settingsEl.querySelectorAll(".server-settings-section");
26
27
 
28
+ initNetworkSettings();
29
+
27
30
  // Open settings
28
31
  settingsBtn.addEventListener("click", function () {
29
32
  openSettings();
@@ -240,6 +243,8 @@ function switchSection(sectionName) {
240
243
  sections[j].classList.toggle("active", isActive2);
241
244
  }
242
245
 
246
+ if (sectionName === "network") loadNetworkSettings();
247
+
243
248
  // Lazy-load section data
244
249
  if (sectionName === "claudemd") loadGlobalClaudeMd();
245
250
  if (sectionName === "environment") loadSharedEnv();
@@ -255,6 +260,7 @@ function openSettings() {
255
260
  settingsBtn.classList.add("active");
256
261
  refreshIcons(settingsEl);
257
262
  populateSettings();
263
+ if (settingsEl.querySelector('.server-settings-section.active[data-section="network"]')) loadNetworkSettings();
258
264
  requestDaemonConfig();
259
265
  resetRestartButton();
260
266
  resetShutdownForm();
@@ -21,6 +21,7 @@
21
21
  @import url("css/diff.css");
22
22
  @import url("css/highlight.css");
23
23
  @import url("css/server-settings.css");
24
+ @import url("css/server-network-settings.css");
24
25
  @import url("css/sticky-notes.css");
25
26
  @import url("css/skills.css");
26
27
  @import url("css/mobile-nav.css?v=20260407");
package/lib/sdk-bridge.js CHANGED
@@ -539,9 +539,10 @@ function createSDKBridge(opts) {
539
539
  return { behavior: "allow", updatedInput: input };
540
540
  }
541
541
 
542
- // Session search is read-only; its handler enforces project and session access.
543
- if (toolName === "search_other_driver_sessions"
544
- || toolName === "mcp__clay-handoff__search_other_driver_sessions") {
542
+ // Session discovery and reads enforce project and session access in their handlers.
543
+ if (["list_other_driver_sessions", "search_other_driver_sessions", "read_other_driver_session"].some(function (name) {
544
+ return toolName === name || toolName === "mcp__clay-handoff__" + name;
545
+ })) {
545
546
  return { behavior: "allow", updatedInput: input };
546
547
  }
547
548
 
@@ -0,0 +1,99 @@
1
+ var { normalizeAllowedOrigins, describeConfiguredOrigins } = require("./network-origins");
2
+
3
+ var MAX_BODY_BYTES = 220000;
4
+
5
+ function attachNetworkSettings(ctx) {
6
+ function reply(res, status, data) {
7
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
8
+ res.end(JSON.stringify(data));
9
+ }
10
+
11
+ function authorized(req, res) {
12
+ if (!ctx.isRequestAuthed(req)) {
13
+ reply(res, 401, { error: "Authentication required." });
14
+ return false;
15
+ }
16
+ if (ctx.users.isMultiUser()) {
17
+ var user = ctx.getMultiUserFromReq(req);
18
+ if (!user || user.role !== "admin") {
19
+ reply(res, 403, { error: "Only server administrators can manage network settings." });
20
+ return false;
21
+ }
22
+ }
23
+ return true;
24
+ }
25
+
26
+ function handleRequest(req, res, fullUrl) {
27
+ if (fullUrl !== "/api/server/network") return false;
28
+ if (!authorized(req, res)) return true;
29
+ if (req.method === "GET") {
30
+ reply(res, 200, describeConfiguredOrigins(ctx.getConfiguredOrigins()));
31
+ return true;
32
+ }
33
+ if (req.method !== "PUT") {
34
+ reply(res, 405, { error: "Method not allowed." });
35
+ return true;
36
+ }
37
+ // A custom header forces cross-origin browsers to preflight. OPTIONS never
38
+ // reaches this handler: the global preflight handler in server.js answers
39
+ // first and allows only GET and Content-Type, so a cross-origin PUT with
40
+ // this header is refused by the browser. Keep both checks below, and keep
41
+ // that handler from allowing PUT or custom headers (see the preflight test).
42
+ if (req.headers["x-clay-network-settings"] !== "1" ||
43
+ (req.headers["content-type"] || "").split(";")[0].trim().toLowerCase() !== "application/json" ||
44
+ (req.headers["sec-fetch-site"] && ["same-origin", "none"].indexOf(req.headers["sec-fetch-site"]) === -1)) {
45
+ reply(res, 403, { error: "Save network settings from the Clay settings page." });
46
+ return true;
47
+ }
48
+ var body = "";
49
+ var size = 0;
50
+ var oversized = false;
51
+ // Decode as a stream so multibyte characters split across chunks stay intact.
52
+ req.setEncoding("utf8");
53
+ req.on("data", function (chunk) {
54
+ size += Buffer.byteLength(chunk);
55
+ if (size > MAX_BODY_BYTES) {
56
+ if (!oversized) reply(res, 413, { error: "Network settings are too large." });
57
+ oversized = true;
58
+ return;
59
+ }
60
+ body += chunk;
61
+ });
62
+ req.on("end", function () {
63
+ if (oversized) return;
64
+ if (!authorized(req, res)) return;
65
+ var data;
66
+ try { data = JSON.parse(body); } catch (e) {
67
+ reply(res, 400, { error: "The request was not valid JSON." });
68
+ return;
69
+ }
70
+ var origins;
71
+ try {
72
+ origins = normalizeAllowedOrigins(data && data.allowedOrigins);
73
+ } catch (e) {
74
+ var result = { error: e.message };
75
+ if (typeof e.index === "number") {
76
+ result.index = e.index;
77
+ result.reason = e.entryReason;
78
+ }
79
+ reply(res, 400, result);
80
+ return;
81
+ }
82
+ try {
83
+ if (typeof ctx.opts.onSetAllowedOrigins !== "function") {
84
+ reply(res, 503, { error: "Network settings cannot be saved in this server runtime." });
85
+ return;
86
+ }
87
+ ctx.opts.onSetAllowedOrigins(origins);
88
+ reply(res, 200, { allowedOrigins: origins, invalid: [] });
89
+ } catch (e) {
90
+ reply(res, 500, { error: "Could not save network settings. Your previous settings are unchanged." });
91
+ }
92
+ });
93
+ return true;
94
+ }
95
+
96
+ return { handleRequest: handleRequest };
97
+ }
98
+
99
+ module.exports = { attachNetworkSettings: attachNetworkSettings };
@@ -3,6 +3,7 @@ var catalog = require('./speech-catalog');
3
3
  var attachSpeechStore = require('./speech-store').attachSpeechStore;
4
4
  var transcribe = require('./speech-transcribe').transcribe;
5
5
  var openSpeechStream = require('./speech-stream').openSpeechStream;
6
+ var isAllowedOrigin = require('./network-origins').isAllowedOrigin;
6
7
  var MAX_AUDIO = 10 * 1024 * 1024;
7
8
 
8
9
  function attachSpeech(ctx) {
@@ -18,8 +19,7 @@ function attachSpeech(ctx) {
18
19
  }
19
20
  function sameOrigin(req) {
20
21
  if (req.headers['sec-fetch-site'] === 'cross-site') return false;
21
- if (!req.headers.origin) return true;
22
- try { return new URL(req.headers.origin).host === req.headers.host; } catch (error) { return false; }
22
+ return isAllowedOrigin(req.headers.origin, req.headers.host, ctx.getAllowedOrigins ? ctx.getAllowedOrigins() : []);
23
23
  }
24
24
  function json(res, status, body) {
25
25
  if (res.destroyed || res.writableEnded) return;
package/lib/server.js CHANGED
@@ -23,6 +23,9 @@ var serverCursorSharing = require("./server-cursor-sharing");
23
23
  var serverTools = require("./server-tools");
24
24
  var serverAdmin = require("./server-admin");
25
25
  var serverSettings = require("./server-settings");
26
+ var { attachNetworkSettings } = require("./server-network-settings");
27
+ var { isAllowedOrigin } = require("./network-origins");
28
+ var { createOriginRejectionLogger } = require("./network-origin-diagnostics");
26
29
  var serverSpeech = require("./server-speech");
27
30
  var serverPalette = require("./server-palette");
28
31
  var workspaceQueryModule = require("./workspace-query-service");
@@ -441,7 +444,22 @@ function createServer(opts) {
441
444
  });
442
445
 
443
446
  var speech = serverSpeech.attachSpeech({ users: users, getMultiUserFromReq: getMultiUserFromReq,
444
- isRequestAuthed: isRequestAuthed, CONFIG_DIR: CONFIG_DIR });
447
+ isRequestAuthed: isRequestAuthed, CONFIG_DIR: CONFIG_DIR, getAllowedOrigins: getAllowedOrigins });
448
+
449
+ function getConfiguredOrigins() {
450
+ return opts.onGetAllowedOrigins ? opts.onGetAllowedOrigins() : opts.allowedOrigins;
451
+ }
452
+
453
+ function getAllowedOrigins() {
454
+ var origins = getConfiguredOrigins();
455
+ return Array.isArray(origins) ? origins : [];
456
+ }
457
+
458
+ var networkSettings = attachNetworkSettings({
459
+ users: users, getMultiUserFromReq: getMultiUserFromReq,
460
+ isRequestAuthed: isRequestAuthed, opts: opts, getConfiguredOrigins: getConfiguredOrigins,
461
+ });
462
+ var logRejectedOrigin = createOriginRejectionLogger({ log: opts.onOriginRejectedLog });
445
463
 
446
464
  var settings = serverSettings.attachSettings({
447
465
  users: users,
@@ -644,6 +662,7 @@ function createServer(opts) {
644
662
  }
645
663
 
646
664
  // CORS preflight for cross-origin requests (HTTP onboarding → HTTPS)
665
+ // Network settings PUT relies on this never allowing PUT or custom headers.
647
666
  if (req.method === "OPTIONS") {
648
667
  res.writeHead(204, {
649
668
  "Access-Control-Allow-Origin": "*",
@@ -780,6 +799,7 @@ function createServer(opts) {
780
799
  }
781
800
 
782
801
  if (speech.handleRequest(req, res, fullUrl)) return;
802
+ if (networkSettings.handleRequest(req, res, fullUrl)) return;
783
803
  if (settings.handleRequest(req, res, fullUrl)) return;
784
804
 
785
805
  // --- Admin API endpoints (multi-user mode only) ---
@@ -1114,33 +1134,11 @@ function createServer(opts) {
1114
1134
  if (speech.handleUpgrade(req, socket, head)) return;
1115
1135
  var wsRequest = parseWsRequestUrl(req.url);
1116
1136
  var wsPathname = wsRequest.path;
1117
- // Origin validation (CSRF prevention)
1118
- var origin = req.headers.origin;
1119
- if (origin) {
1120
- try {
1121
- var originUrl = new URL(origin);
1122
- var originPort = String(originUrl.port || (originUrl.protocol === "https:" ? "443" : "80"));
1123
- // Extract port from Host header for reverse proxy support.
1124
- // Use URL parser to correctly handle IPv6 addresses (e.g. [::1])
1125
- // and infer default port from origin protocol (not backend tlsOptions)
1126
- // so TLS-terminating proxies on :443 with HTTP backends work.
1127
- var hostPort;
1128
- try {
1129
- var hostUrl = new URL(originUrl.protocol + "//" + (req.headers.host || ""));
1130
- hostPort = String(hostUrl.port || (originUrl.protocol === "https:" ? "443" : "80"));
1131
- } catch (e2) {
1132
- hostPort = String(portNum);
1133
- }
1134
- if (originPort !== String(portNum) && originPort !== hostPort) {
1135
- socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1136
- socket.destroy();
1137
- return;
1138
- }
1139
- } catch (e) {
1140
- socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1141
- socket.destroy();
1142
- return;
1143
- }
1137
+ if (!isAllowedOrigin(req.headers.origin, req.headers.host, getAllowedOrigins())) {
1138
+ logRejectedOrigin(req.headers.origin, req.headers.host);
1139
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1140
+ socket.destroy();
1141
+ return;
1144
1142
  }
1145
1143
 
1146
1144
  if (!isRequestAuthed(req)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.7.0",
3
+ "version": "4.7.1-beta.2",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"