Skip to content

Commit da87a4f

Browse files
JohnMcLearclaude
andauthored
fix(7686): username 'false' / 'malformed color: false' for legacy settings.json (#7688)
* fix(7686): legacy padOptions.userName/userColor=false breaks pad Settings.json files generated before December 2021 used `false` as the default for these two string options (commit 8c857a8 switched the template default to `null` and noted "this change has no effect due to a bug in how pad options are processed; that bug will be fixed in a future commit" — the follow-up never landed). pad.ts:getParams() then runs `false.toString()`, the resulting string "false" passes the `!== false` sentinel check at _afterHandshake, and notifyChangeName ships USERINFO_UPDATE with name="false" and colorId="false" (clobbered via clientVars.userColor). The server's hex regex rejects the colour and throws `malformed color: false`; the user sees their name as "false" and a white swatch. Defense in depth: - Server: Settings.ts::reloadSettings() coerces legacy boolean false to null for padOptions.userName / padOptions.userColor and warns the operator, matching the existing disableIPlogging shim pattern. - Client: the pad.ts userName / userColor callbacks reject the literal "false" string so URL params (?userName=false) and any other path that surfaces the sentinel as a string are also no-ops. - Backend regression test mirrors the shim and asserts it normalizes legacy false, leaves explicit values intact, leaves null untouched, does not coerce other padOptions keys, and does not coerce the string "false" (that path is the client guard's responsibility). Closes #7686 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(7686): guard padOptions shim against non-object config (Qodo) Qodo flagged that storeSettings() will overwrite settings.padOptions raw with whatever settings.json supplies — including null, primitives, or arrays — which would make the new userName/userColor shim crash on property access. Add a shape guard so the shim is a no-op for malformed padOptions, and extend the regression test to cover null / primitive / array shapes. This doesn't change which configs work (Pad.ts also assumes padOptions is an object and would already crash on a null padOptions when a pad is opened) but it stops the shim from being the loud thing in the stack trace if someone hits it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 45b000d commit da87a4f

3 files changed

Lines changed: 131 additions & 0 deletions

File tree

src/node/utils/Settings.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,30 @@ export const reloadSettings = () => {
10871087
settings.privacyBanner.dismissal = 'dismissible';
10881088
}
10891089

1090+
// Settings.json files generated before December 2021 used `false` as the
1091+
// default for these string options. The client treats the boolean `false`
1092+
// as a sentinel meaning "no enforced value", but the dispatch in
1093+
// pad.ts:getParams() coerces the boolean to the string "false" before
1094+
// applying it, which then propagates as the user's name and color and
1095+
// triggers `malformed color: false` on the server (#7686). Normalize
1096+
// legacy booleans to null at the boundary so downstream code sees the
1097+
// expected sentinel. Guard against a malformed padOptions (null, array,
1098+
// primitive) — storeSettings() will overwrite it raw if settings.json
1099+
// declares it as anything other than a plain object.
1100+
if (settings.padOptions != null
1101+
&& typeof settings.padOptions === 'object'
1102+
&& !Array.isArray(settings.padOptions)) {
1103+
for (const key of ['userName', 'userColor'] as const) {
1104+
if ((settings.padOptions as any)[key] === false) {
1105+
logger.warn(
1106+
`padOptions.${key}=false is a legacy default (pre-2021) and is ` +
1107+
`now treated as null. Update settings.json to use null instead ` +
1108+
`to silence this warning.`);
1109+
(settings.padOptions as any)[key] = null;
1110+
}
1111+
}
1112+
}
1113+
10901114
// Init logging config
10911115
settings.logconfig = defaultLogConfig(
10921116
settings.loglevel ? settings.loglevel : defaultLogLevel,

src/static/js/pad.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ const getParameters = [
136136
name: 'userName',
137137
checkVal: null,
138138
callback: (val) => {
139+
// The default for globalUserName/globalUserColor is the boolean `false`
140+
// (sentinel meaning "no enforced value"). Older settings.json files used
141+
// boolean `false` for these options too, which getParams() coerces to
142+
// the string "false" — that fooled the !== false sentinel checks at
143+
// _afterHandshake and shipped the literal string "false" as the user's
144+
// name and color (#7686). Reject the sentinel string here so URL
145+
// parameters like ?userName=false also no-op.
146+
if (!val || val === 'false') return;
139147
settings.globalUserName = val;
140148
clientVars.userName = val;
141149
},
@@ -144,6 +152,7 @@ const getParameters = [
144152
name: 'userColor',
145153
checkVal: null,
146154
callback: (val) => {
155+
if (!val || val === 'false') return;
147156
settings.globalUserColor = val;
148157
clientVars.userColor = val;
149158
},
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use strict';
2+
3+
import {strict as assert} from 'assert';
4+
5+
describe(__filename, function () {
6+
// Replicates the shim block from Settings.ts::reloadSettings so we can
7+
// assert the mapping without rebooting the whole server in this spec.
8+
// Keep this in lockstep with the production block — if you change the
9+
// shim there, change it here too.
10+
const applyShim = (padOptions: any) => {
11+
if (padOptions != null && typeof padOptions === 'object' && !Array.isArray(padOptions)) {
12+
for (const key of ['userName', 'userColor']) {
13+
if (padOptions[key] === false) padOptions[key] = null;
14+
}
15+
}
16+
return padOptions;
17+
};
18+
19+
describe('legacy padOptions.userName/userColor=false → null shim', function () {
20+
it('coerces userName=false to null', function () {
21+
const out = applyShim({userName: false});
22+
assert.strictEqual(out.userName, null);
23+
});
24+
25+
it('coerces userColor=false to null', function () {
26+
const out = applyShim({userColor: false});
27+
assert.strictEqual(out.userColor, null);
28+
});
29+
30+
it('coerces both legacy defaults in one pass', function () {
31+
const out = applyShim({userName: false, userColor: false});
32+
assert.strictEqual(out.userName, null);
33+
assert.strictEqual(out.userColor, null);
34+
});
35+
36+
it('leaves an explicit string userName intact', function () {
37+
const out = applyShim({userName: 'Etherpad User'});
38+
assert.strictEqual(out.userName, 'Etherpad User');
39+
});
40+
41+
it('leaves an explicit hex userColor intact', function () {
42+
const out = applyShim({userColor: '#ff9900'});
43+
assert.strictEqual(out.userColor, '#ff9900');
44+
});
45+
46+
it('leaves null values untouched', function () {
47+
const out = applyShim({userName: null, userColor: null});
48+
assert.strictEqual(out.userName, null);
49+
assert.strictEqual(out.userColor, null);
50+
});
51+
52+
it('does not affect other padOptions keys that legitimately use false', function () {
53+
// showChat:false, rtl:false, etc. are real, meaningful values — only
54+
// the two string options carry the legacy boolean sentinel.
55+
const out = applyShim({
56+
userName: false,
57+
userColor: false,
58+
showChat: false,
59+
rtl: false,
60+
useMonospaceFont: false,
61+
});
62+
assert.strictEqual(out.userName, null);
63+
assert.strictEqual(out.userColor, null);
64+
assert.strictEqual(out.showChat, false);
65+
assert.strictEqual(out.rtl, false);
66+
assert.strictEqual(out.useMonospaceFont, false);
67+
});
68+
69+
it('does not coerce the string "false" — that is handled in the client guard', function () {
70+
// The server-side shim only normalizes the boolean sentinel from
71+
// legacy settings.json. URL-supplied or stringified "false" is
72+
// rejected by pad.ts::getParameters.userName/userColor callbacks.
73+
const out = applyShim({userName: 'false', userColor: 'false'});
74+
assert.strictEqual(out.userName, 'false');
75+
assert.strictEqual(out.userColor, 'false');
76+
});
77+
78+
it('skips the shim if padOptions is null', function () {
79+
// storeSettings() overwrites settings.padOptions raw if settings.json
80+
// declares it as a non-object — the shim must not throw on that.
81+
assert.doesNotThrow(() => applyShim(null));
82+
assert.strictEqual(applyShim(null), null);
83+
});
84+
85+
it('skips the shim if padOptions is a primitive', function () {
86+
assert.doesNotThrow(() => applyShim(false));
87+
assert.doesNotThrow(() => applyShim('not an object'));
88+
assert.doesNotThrow(() => applyShim(42));
89+
});
90+
91+
it('skips the shim if padOptions is an array', function () {
92+
const arr: any = [false, false];
93+
assert.doesNotThrow(() => applyShim(arr));
94+
// Arrays pass through untouched — index 0/1 (numeric) are not coerced.
95+
assert.deepEqual(applyShim(arr), [false, false]);
96+
});
97+
});
98+
});

0 commit comments

Comments
 (0)