Skip to content

Commit 5f40146

Browse files
committed
merge: land security hardening batch 1 on beta
2 parents 47725e3 + fb06087 commit 5f40146

18 files changed

Lines changed: 507 additions & 84 deletions

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ product overview.
1818
1919
## Unreleased
2020

21+
- Security hardening (audit batch 1): the Slack Settings mode control no longer lets a non-admin
22+
channel manager switch a channel into Admin mode (managers keep their other controls in an
23+
Admin-mode channel and may step it down); case-variant URLs such as `/API/settings` are refused
24+
by the admin guards and never routed; the admin shell and login page carry a no-inline-script
25+
Content-Security-Policy and every response carries nosniff, frame-denial and referrer headers;
26+
an Express error handler replaces default stack-trace responses and the systemd unit sets
27+
`NODE_ENV=production`; `/resume` requires the admin session; the background-job "Check status"
28+
button is gated like `/status` and scoped to its channel; file explorer share, send-to-me,
29+
preview and edit re-prove the opened file under the channel root; the License card escapes
30+
platform-supplied text; the internal IPC secret is compared in constant time; `npm audit fix`
31+
moved undici, zod, hono, fast-uri, body-parser and ip-address to patched releases.
32+
2133
- Supply new installations with Astra, Sol, Terra, Fable, Opus and Sonnet (`:sonet`) shortcuts
2234
using the newest supported stable family versions. Preserve customized lists and thread pins.
2335

‎FEATURES.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
# ChannelGate — Features
22

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

539
- Current organization admins can open Files, Variables, Settings and legacy menu Resume controls

‎TEST-PLAN.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,38 @@
11
# ChannelGate — Test Plan
22

3+
## Web and Slack surface hardening, batch 1 (2026-10-25)
4+
5+
- [x] Automated: `node scripts/run-tests.mjs test/web-hardening.test.js test/access-settings.test.js
6+
test/admin-auth-bind.test.js test/admin-navigation.test.js test/run-control-auth.test.js
7+
test/file-explorer-auth.test.js test/asset-versioning.test.js` — `/API/...` refused by the
8+
lockdown / 401 branch and never routed; CSP + nosniff + frame denial on the shell and login page,
9+
the import-map hash matching the served map, no inline script or handler in either page; a
10+
malformed JSON body answers a bare 400 and the final 4-arity error handler is registered; the
11+
internal IPC secret compared with `timingSafeEqualStr`; a manager's Settings save that selects
12+
Admin is refused and writes nothing while an admin's succeeds, a manager keeps Admin when
13+
changing flags and may step down, the picker lists "Admin" only to an admin or when current;
14+
the `cg_bgjob_status` handler calls `authorizedControlEntry` and compares the job's slug; share /
15+
send-to-me pass `rootPath`, preview and edit open through the `/proc/self/fd` re-check; the
16+
License card escapes; the unit sets `NODE_ENV=production`.
17+
- [x] `npm run check:static`, `npm run secret-scan`, `npm audit --omit=dev` (0 vulnerabilities).
18+
- [ ] Live, engine-independent (browser): sign in to the admin UI through the deployment's public
19+
hostname. Expected: the page renders and navigates normally (Overview, Conversations, Settings,
20+
Skills, System health); the browser console shows no CSP violation; `curl -i https://<host>/`
21+
shows `Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-…'` and
22+
`X-Frame-Options: DENY`; `curl -i https://<host>/API/settings` answers 401 (password set) with
23+
no settings payload; `curl -i -X POST https://<host>/api/login -H 'content-type: application/json'
24+
-d '{bad'` answers 400 with `{"error":…}` and no stack. PASS when all four hold.
25+
- [ ] Live, Slack, engine-independent: in a test channel with `manageAccess: members` open Settings
26+
as an approved NON-admin member. Expected: the Mode picker offers Read-only and Worker only;
27+
switching Auto on succeeds and the channel stays in its mode. As an admin, pick Admin: the channel
28+
enters Admin mode. Back as the member: the picker now shows Admin (current), ticking Network
29+
succeeds and the channel stays in Admin mode, picking Worker steps it down. PASS when every step
30+
matches and the audit events name the clicker.
31+
- [ ] Live, Slack, Claude AND Codex: in a Worker channel start `run_in_background` with
32+
`sleep 90; echo done` (one run per engine). As an approved member click "Check status": an
33+
ephemeral status appears. As a workspace member who is NOT approved and not a guest of the
34+
channel, click the same button: nothing is posted. PASS when both hold for both engines.
35+
336
## Harness default effort (2026-10-23)
437

538
- [x] Automated: `node --test test/default-model.test.js test/qwen-provider-settings-route.test.js

‎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
@@ -4238,8 +4238,8 @@ const LICENSE_STATE_LABEL = {
42384238
expired_grace: "· grace expired",
42394239
};
42404240

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

42444244
function paintLicense(l) {
42454245
document.getElementById("license-state-chip").textContent = LICENSE_STATE_LABEL[l.state] || `· ${l.state}`;
@@ -4253,9 +4253,11 @@ function paintLicense(l) {
42534253
banner.textContent = l.banner?.text || "";
42544254
banner.hidden = !l.banner;
42554255

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