Skip to content

Commit 79df51b

Browse files
committed
merge: refresh login limiter branch against beta (keep both doc sections)
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
2 parents 1634374 + 5f40146 commit 79df51b

18 files changed

Lines changed: 504 additions & 84 deletions

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ product overview.
2424
header that arrives on a loopback socket, and a global cap (50 failures per 10 minutes) bounds
2525
password guessing from fresh buckets. Deployments behind a proxy should set the new option;
2626
without it remote logins share one backoff bucket.
27+
- Security hardening (audit batch 1): the Slack Settings mode control no longer lets a non-admin
28+
channel manager switch a channel into Admin mode (managers keep their other controls in an
29+
Admin-mode channel and may step it down); case-variant URLs such as `/API/settings` are refused
30+
by the admin guards and never routed; the admin shell and login page carry a no-inline-script
31+
Content-Security-Policy and every response carries nosniff, frame-denial and referrer headers;
32+
an Express error handler replaces default stack-trace responses and the systemd unit sets
33+
`NODE_ENV=production`; `/resume` requires the admin session; the background-job "Check status"
34+
button is gated like `/status` and scoped to its channel; file explorer share, send-to-me,
35+
preview and edit re-prove the opened file under the channel root; the License card escapes
36+
platform-supplied text; the internal IPC secret is compared in constant time; `npm audit fix`
37+
moved undici, zod, hono, fast-uri, body-parser and ip-address to patched releases.
2738

2839
- Supply new installations with Astra, Sol, Terra, Fable, Opus and Sonnet (`:sonet`) shortcuts
2940
using the newest supported stable family versions. Preserve customized lists and thread pins.

‎FEATURES.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,39 @@
1616
still bounded, and so is the scrypt work an unauthenticated caller can cause.
1717
- Documented in `docs/OPERATIONS.md` → Reverse proxies and tunnels and in INSTALL.md: without the
1818
setting, all remote logins behind a proxy share one backoff bucket.
19+
## Web and Slack surface hardening (security audit batch 1)
20+
21+
- **Admin mode is admin-only on every surface.** The Slack Settings mode control refuses to switch
22+
a channel into Admin mode unless the clicker is a current organization admin, the same rule
23+
`/mode admin` and the `set_channel_admin_mode` tool already enforce. A channel manager keeps
24+
every other control in a channel that is already in Admin mode (changing Auto, Lean or the
25+
network switch leaves the mode alone) and may step the channel down to Worker or Read-only; the
26+
"Admin" option is listed only for someone who may enable it or when it is the current mode.
27+
- **Case-variant URLs never reach the admin router.** Express case-sensitive routing is on, and the
28+
Host/Origin guard, the no-password lockdown, the 401 branch and the run-API key gate all compare
29+
lowercased paths, so `/API/settings` is refused exactly like `/api/settings` instead of slipping
30+
past them into the router.
31+
- **Security headers.** Every response carries `X-Content-Type-Options: nosniff`,
32+
`X-Frame-Options: DENY` and `Referrer-Policy: same-origin`. The admin shell and the login page are
33+
served with a Content-Security-Policy that admits same-origin scripts only plus the import map
34+
by content hash (no inline script anywhere; the login handler lives in `public/login.js`),
35+
same-origin styles with inline attributes, same-origin fonts, images and connections,
36+
`frame-ancestors 'none'`, `object-src 'none'`.
37+
- **No stack traces.** One Express error handler answers unexpected failures with a bare 500 (logged
38+
server-side) and keeps exposable client errors such as a malformed JSON body as their 4xx with
39+
the message only; the systemd unit also sets `NODE_ENV=production`. `/resume` requires the admin
40+
session; the `/internal/*` IPC secret is compared in constant time.
41+
- **Background-job status button.** "Check status" on a background job card runs through the same
42+
authorization gate as `/status` (admin, approved, or an explicit guest of that channel) and only
43+
reports a job that belongs to that channel; the log-tail fallback reads the channel's own log.
44+
- **File explorer exports re-prove the file.** Share, Send to me, Preview and Edit open the resolved
45+
file `O_NOFOLLOW` and re-resolve the kernel descriptor through `/proc/self/fd`, refusing a file
46+
that no longer sits under the channel root, which closes the symlink-swap race the agent-side
47+
export tools already closed.
48+
- **License card.** The organization, tier and limit values from the licensing platform are
49+
HTML-escaped before rendering.
50+
- **Dependencies.** `npm audit fix` applied: undici, zod, hono, fast-uri, body-parser and
51+
ip-address moved to their patched releases; `npm audit --omit=dev` reports no vulnerabilities.
1952

2053
## Admin access to Slack controls
2154

‎TEST-PLAN.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,38 @@
2222
with the setting at **None**: every request shares the loopback bucket, so the 4th onwards is
2323
429/delayed regardless of the spoofed header. PASS when both hold and the setting round-trips
2424
after a daemon restart.
25+
## Web and Slack surface hardening, batch 1 (2026-10-25)
26+
27+
- [x] Automated: `node scripts/run-tests.mjs test/web-hardening.test.js test/access-settings.test.js
28+
test/admin-auth-bind.test.js test/admin-navigation.test.js test/run-control-auth.test.js
29+
test/file-explorer-auth.test.js test/asset-versioning.test.js` — `/API/...` refused by the
30+
lockdown / 401 branch and never routed; CSP + nosniff + frame denial on the shell and login page,
31+
the import-map hash matching the served map, no inline script or handler in either page; a
32+
malformed JSON body answers a bare 400 and the final 4-arity error handler is registered; the
33+
internal IPC secret compared with `timingSafeEqualStr`; a manager's Settings save that selects
34+
Admin is refused and writes nothing while an admin's succeeds, a manager keeps Admin when
35+
changing flags and may step down, the picker lists "Admin" only to an admin or when current;
36+
the `cg_bgjob_status` handler calls `authorizedControlEntry` and compares the job's slug; share /
37+
send-to-me pass `rootPath`, preview and edit open through the `/proc/self/fd` re-check; the
38+
License card escapes; the unit sets `NODE_ENV=production`.
39+
- [x] `npm run check:static`, `npm run secret-scan`, `npm audit --omit=dev` (0 vulnerabilities).
40+
- [ ] Live, engine-independent (browser): sign in to the admin UI through the deployment's public
41+
hostname. Expected: the page renders and navigates normally (Overview, Conversations, Settings,
42+
Skills, System health); the browser console shows no CSP violation; `curl -i https://<host>/`
43+
shows `Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-…'` and
44+
`X-Frame-Options: DENY`; `curl -i https://<host>/API/settings` answers 401 (password set) with
45+
no settings payload; `curl -i -X POST https://<host>/api/login -H 'content-type: application/json'
46+
-d '{bad'` answers 400 with `{"error":…}` and no stack. PASS when all four hold.
47+
- [ ] Live, Slack, engine-independent: in a test channel with `manageAccess: members` open Settings
48+
as an approved NON-admin member. Expected: the Mode picker offers Read-only and Worker only;
49+
switching Auto on succeeds and the channel stays in its mode. As an admin, pick Admin: the channel
50+
enters Admin mode. Back as the member: the picker now shows Admin (current), ticking Network
51+
succeeds and the channel stays in Admin mode, picking Worker steps it down. PASS when every step
52+
matches and the audit events name the clicker.
53+
- [ ] Live, Slack, Claude AND Codex: in a Worker channel start `run_in_background` with
54+
`sleep 90; echo done` (one run per engine). As an approved member click "Check status": an
55+
ephemeral status appears. As a workspace member who is NOT approved and not a guest of the
56+
channel, click the same button: nothing is posted. PASS when both hold for both engines.
2557

2658
## Harness default effort (2026-10-23)
2759

‎package-lock.json‎

Lines changed: 28 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎public/app.js‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4240,8 +4240,8 @@ const LICENSE_STATE_LABEL = {
42404240
expired_grace: "· grace expired",
42414241
};
42424242

4243-
const fmtLicenseLimit = (v) => (v === null || v === undefined ? "unlimited" : String(v));
4244-
const fmtLicenseTime = (v) => (v ? new Date(v).toLocaleString() : "—");
4243+
const fmtLicenseLimit = (v) => (v === null || v === undefined ? "unlimited" : escapeHtml(String(v)));
4244+
const fmtLicenseTime = (v) => (v ? escapeHtml(new Date(v).toLocaleString()) : "—");
42454245

42464246
function paintLicense(l) {
42474247
document.getElementById("license-state-chip").textContent = LICENSE_STATE_LABEL[l.state] || `· ${l.state}`;
@@ -4255,9 +4255,11 @@ function paintLicense(l) {
42554255
banner.textContent = l.banner?.text || "";
42564256
banner.hidden = !l.banner;
42574257

4258+
// Everything here comes from the licensing platform's response. The key fields are already
4259+
// escaped below; the free-text ones must be too, because this list is rendered as HTML.
42584260
const facts = [
4259-
["Tier", l.tier === "none" ? "no key" : l.tier],
4260-
["Organization", l.organization || "—"],
4261+
["Tier", l.tier === "none" ? "no key" : escapeHtml(l.tier)],
4262+
["Organization", escapeHtml(l.organization || "—")],
42614263
["Key", l.hasLicenseKey ? `…${escapeHtml(l.licenseKeyLast4)} (from ${escapeHtml(l.licenseKeySource)})` : "none"],
42624264
["Registered channels", fmtLicenseLimit(l.limits?.channels)],
42634265
["Registered users", fmtLicenseLimit(l.limits?.users)],

‎public/login.html‎

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,22 +51,7 @@
5151
<div class="err" id="err"></div>
5252
</form>
5353
</div>
54-
<script>
55-
document.getElementById("f").addEventListener("submit", async (e) => {
56-
e.preventDefault();
57-
const err = document.getElementById("err");
58-
err.textContent = "";
59-
const res = await fetch("/api/login", {
60-
method: "POST",
61-
headers: { "Content-Type": "application/json" },
62-
body: JSON.stringify({ password: document.getElementById("pw").value }),
63-
});
64-
if (res.ok) return void (location.href = "/");
65-
// A 403 here means no admin password is configured at all — show what the server says to
66-
// do about it rather than the misleading "Wrong password."
67-
const body = await res.json().catch(() => ({}));
68-
err.textContent = body.error || "Wrong password.";
69-
});
70-
</script>
54+
<!-- External on purpose: the page is served under a Content-Security-Policy with no inline script. -->
55+
<script src="/login.js"></script>
7156
</body>
7257
</html>

‎public/login.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Sign-in form handler for login.html. Kept out of the page so the admin UI can be served with a
2+
// Content-Security-Policy that admits no inline script.
3+
document.getElementById("f").addEventListener("submit", async (e) => {
4+
e.preventDefault();
5+
const err = document.getElementById("err");
6+
err.textContent = "";
7+
const res = await fetch("/api/login", {
8+
method: "POST",
9+
headers: { "Content-Type": "application/json" },
10+
body: JSON.stringify({ password: document.getElementById("pw").value }),
11+
});
12+
if (res.ok) return void (location.href = "/");
13+
// A 403 here means no admin password is configured at all — show what the server says to
14+
// do about it rather than the misleading "Wrong password."
15+
const body = await res.json().catch(() => ({}));
16+
err.textContent = body.error || "Wrong password.";
17+
});

‎scripts/install-systemd.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ Group=$SERVICE_USER
166166
WorkingDirectory=$APP_DIR
167167
Environment=CHANNELGATE_DIR=$SERVICE_HOME
168168
Environment=HOME=$SERVICE_HOME
169+
Environment=NODE_ENV=production
169170
Environment=PATH=$SERVICE_PATH
170171
Environment=XDG_RUNTIME_DIR=$SERVICE_RUNTIME_DIR
171172
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=$SERVICE_RUNTIME_DIR/bus

‎src/gateway/background.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,7 @@ export class BackgroundJobs {
691691
status(id) {
692692
const j = this.jobs.get(id);
693693
if (!j) return null;
694-
return { id: j.id, kind: j.kind || "shell", label: j.label, runtimeMs: Date.now() - j.startedAt, maxMs: j.maxMs, tail: j.tail || "", finished: Boolean(j.pendingDelivery) };
694+
return { id: j.id, slug: j.slug || "", kind: j.kind || "shell", label: j.label, runtimeMs: Date.now() - j.startedAt, maxMs: j.maxMs, tail: j.tail || "", finished: Boolean(j.pendingDelivery) };
695695
}
696696

697697
// The job's process (or engine run) ended. This is NOT the terminal boundary: the thread is

‎src/slack/access-settings.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,14 @@ export function accessSettingsPatch(meta, form, actor) {
118118
throw new Error("Choose up to 100 valid users per list.");
119119
}
120120
}
121+
// Admin mode is the posture every approval card relies on (permission prompts off for admin
122+
// authors), so only an organization admin may switch a channel INTO it — the same rule `/mode
123+
// admin` and the `set_channel_admin_mode` tool enforce. A manager may still leave it, and may
124+
// change Auto/Lean in a channel that is already in Admin mode: keeping the current mode is not
125+
// enabling it.
126+
const canEnableAdmin = actor.isAdminUser === true || channelMode(meta) === "admin";
121127
const patch = {
122-
...modeSettingsPatch(meta, { mode: form.mode, autoMode: form.autoMode, cleanMode: form.cleanMode }, { canEnableAdmin: true }),
128+
...modeSettingsPatch(meta, { mode: form.mode, autoMode: form.autoMode, cleanMode: form.cleanMode }, { canEnableAdmin }),
123129
allowNetwork: form.allowNetwork, access: form.access, manageAccess: form.manageAccess,
124130
allowedUsers: [...new Set(form.allowedUsers)], managers: [...new Set(form.managers)],
125131
excludedUsers: [...new Set(form.excludedUsers)],

0 commit comments

Comments
 (0)