Skip to content

Commit e459a36

Browse files
Address Copilot review on JavaScriptSolidServer#285
1. XSS-safe inline script. previewConfig is now escaped against the three sequences that can break out of an inline <script> context: `</script>` (via `<` → \u003c), and the U+2028 / U+2029 line separators that JS treats as line terminators. Plain JSON.stringify doesn't handle these on its own. 2. Client/server validation alignment. The HTML pattern now mirrors the server's "..-rejected" rule via a negative lookahead, and in subdomain mode it tightens to alphanumeric + hyphen only (matching server-side behaviour added below). title text updated to match. 3. Subdomain-mode regex. server.js refuses to route multi-level subdomains, so `alice.smith` would create a non-addressable pod. handleRegisterPost now picks /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/ when request.subdomainsEnabled && request.baseDomain, and uses a distinct error message so the user knows why dot/underscore is out. 4. Lowercase normalisation. The username field is now coerced to lowercase as the user types, so the live preview matches what the server will accept (it rejects uppercase outright). 5. Test coverage. Adds 9 path-mode register cases (accepted: alice, alice-smith, alice.smith, alice_work; rejected: leading separator, trailing separator, consecutive dots, uppercase, too-short) and 3 subdomain-mode cases (dash accepted; dot + underscore rejected). Also picks up the .jsonld pod creation flow from JavaScriptSolidServer#283 transitively.
1 parent 528c9e1 commit e459a36

3 files changed

Lines changed: 158 additions & 9 deletions

File tree

src/idp/interactions.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,9 +403,20 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
403403
// `alice_work`, and so on. No leading/trailing separators (avoids the
404404
// `.hidden` / trailing-dot footguns), no `..` (path traversal hygiene
405405
// even though storage already guards against it).
406-
const usernameRegex = /^[a-z0-9]([a-z0-9._-]{1,30}[a-z0-9])?$/;
406+
//
407+
// In subdomain mode the username becomes a single-level subdomain — DNS
408+
// hostnames don't allow `.` or `_`, and `server.js` already refuses to
409+
// route multi-level subdomains as pods. So we restrict to alphanumeric +
410+
// hyphen there to keep the username and the pod actually addressable.
411+
const subdomainMode = !!(request.subdomainsEnabled && request.baseDomain);
412+
const usernameRegex = subdomainMode
413+
? /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/
414+
: /^[a-z0-9]([a-z0-9._-]{1,30}[a-z0-9])?$/;
407415
if (!usernameRegex.test(username)) {
408-
return reply.type('text/html').send(registerPage(uid, 'Username must be lowercase letters, numbers, or . _ - (start and end alphanumeric)', null, inviteOnly, ctx));
416+
const msg = subdomainMode
417+
? 'Username must be lowercase letters, numbers, or - (subdomain mode disallows . and _)'
418+
: 'Username must be lowercase letters, numbers, or . _ - (start and end alphanumeric)';
419+
return reply.type('text/html').send(registerPage(uid, msg, null, inviteOnly, ctx));
409420
}
410421
if (username.includes('..')) {
411422
return reply.type('text/html').send(registerPage(uid, 'Username cannot contain ".."', null, inviteOnly, ctx));

src/idp/views.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -560,14 +560,28 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
560560
placeholder="Enter your invite code" style="text-transform: uppercase;">
561561
` : '';
562562

563-
// Embed the values the live preview needs. Treat as opaque strings — the
564-
// template substitution is JSON-serialised, so quotes / scripts inside
565-
// baseUri can't break out.
563+
// Embed the values the live preview needs. Escape characters that are
564+
// unsafe in inline <script> contexts so values like "</script>" or
565+
// U+2028 / U+2029 line separators can't terminate the script tag or
566+
// confuse the parser when template-substituted.
566567
const previewConfig = JSON.stringify({
567568
baseUri: ctx.baseUri || '',
568569
subdomainsEnabled: !!ctx.subdomainsEnabled,
569570
baseDomain: ctx.baseDomain || '',
570-
});
571+
})
572+
.replace(/</g, '\\u003c')
573+
.replace(/\u2028/g, '\\u2028')
574+
.replace(/\u2029/g, '\\u2029');
575+
576+
// Server validates more strictly than the HTML pattern can express; mirror
577+
// as much as possible client-side so the browser catches obvious mistakes
578+
// before submit. Subdomain mode drops dot/underscore (DNS hostname rules).
579+
const usernamePattern = (ctx.subdomainsEnabled && ctx.baseDomain)
580+
? '[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?'
581+
: '(?!.*\\.\\.)[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?';
582+
const usernameTitle = (ctx.subdomainsEnabled && ctx.baseDomain)
583+
? 'Lowercase letters, numbers, or - (start and end alphanumeric, 3–32 chars). Subdomain mode disallows . and _.'
584+
: 'Lowercase letters, numbers, or . _ - (start and end alphanumeric, 3–32 chars, no consecutive dots)';
571585

572586
return `
573587
<!DOCTYPE html>
@@ -618,8 +632,8 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
618632
<label for="username">Username</label>
619633
<input type="text" id="username" name="username" required ${!inviteOnly ? 'autofocus' : ''}
620634
placeholder="Choose a username" minlength="3" maxlength="32"
621-
pattern="[a-z0-9]([a-z0-9._-]{1,30}[a-z0-9])?"
622-
title="Lowercase letters, numbers, or . _ - (start and end alphanumeric, 3–32 chars)">
635+
pattern="${usernamePattern}"
636+
title="${usernameTitle}">
623637
624638
<div class="preview" id="preview" aria-live="polite">
625639
<div><span class="label">WebID</span><span id="preview-webid" class="placeholder">choose a username to preview</span></div>
@@ -651,7 +665,12 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
651665
if (!input || !webEl || !storEl) return;
652666
653667
function render() {
654-
var u = (input.value || '').trim().toLowerCase();
668+
// Server rejects uppercase outright, so normalise the field as the
669+
// user types — keeps the preview honest and avoids a confusing
670+
// post-submit error.
671+
var normalised = (input.value || '').toLowerCase();
672+
if (input.value !== normalised) input.value = normalised;
673+
var u = normalised.trim();
655674
if (!u) {
656675
webEl.textContent = 'choose a username to preview';
657676
webEl.className = 'placeholder';

test/idp.test.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,125 @@ describe('Identity Provider', () => {
181181
assert.ok(res.status >= 200 && res.status < 600, `got valid HTTP status ${res.status}`);
182182
});
183183
});
184+
185+
// Regression coverage for #284 — relaxed username regex + `..` rejection.
186+
// Each register call below also exercises the .jsonld pod-creation flow
187+
// from #283, since handleRegisterPost calls createPodStructure on success.
188+
describe('Register username validation (path mode)', () => {
189+
async function tryRegister(username) {
190+
const res = await fetch(`${baseUrl}/idp/register`, {
191+
method: 'POST',
192+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
193+
body: new URLSearchParams({ username, password: 'secret-password', confirmPassword: 'secret-password' }),
194+
});
195+
const body = await res.text();
196+
return { status: res.status, body };
197+
}
198+
199+
it('accepts plain alphanumeric (alice)', async () => {
200+
const r = await tryRegister('alice');
201+
assert.match(r.body, /Account created/);
202+
});
203+
204+
it('accepts dash (alice-smith)', async () => {
205+
const r = await tryRegister('alice-smith');
206+
assert.match(r.body, /Account created/);
207+
});
208+
209+
it('accepts dot (alice.smith)', async () => {
210+
const r = await tryRegister('alice.smith');
211+
assert.match(r.body, /Account created/);
212+
});
213+
214+
it('accepts underscore (alice_work)', async () => {
215+
const r = await tryRegister('alice_work');
216+
assert.match(r.body, /Account created/);
217+
});
218+
219+
it('rejects leading separator (.alice)', async () => {
220+
const r = await tryRegister('.alice');
221+
assert.match(r.body, /lowercase letters, numbers/);
222+
});
223+
224+
it('rejects trailing separator (alice-)', async () => {
225+
const r = await tryRegister('alice-');
226+
assert.match(r.body, /lowercase letters, numbers/);
227+
});
228+
229+
it('rejects consecutive dots (alice..bob)', async () => {
230+
const r = await tryRegister('alice..bob');
231+
// Quotes are HTML-escaped (&quot;) in the rendered error banner.
232+
assert.match(r.body, /cannot contain (?:"|&quot;)\.\.(?:"|&quot;)/);
233+
});
234+
235+
it('rejects uppercase (Alice)', async () => {
236+
const r = await tryRegister('Alice');
237+
assert.match(r.body, /lowercase letters, numbers/);
238+
});
239+
240+
it('rejects too short (ab)', async () => {
241+
const r = await tryRegister('ab');
242+
// Two-char names fail the regex (min 3 enforced by the pattern itself).
243+
assert.match(r.body, /lowercase letters, numbers|at least 3/);
244+
});
245+
});
246+
});
247+
248+
// Subdomain mode: usernames become hostname components, so `.` and `_` are
249+
// not allowed (server.js refuses to route multi-level subdomains).
250+
describe('Identity Provider - Subdomain mode register validation', () => {
251+
let server;
252+
let baseUrl;
253+
const SUBDOMAIN_DATA_DIR = './test-data-idp-subdomain';
254+
255+
before(async () => {
256+
await fs.remove(SUBDOMAIN_DATA_DIR);
257+
await fs.ensureDir(SUBDOMAIN_DATA_DIR);
258+
259+
const port = await getAvailablePort();
260+
baseUrl = `http://${TEST_HOST}:${port}`;
261+
262+
server = createServer({
263+
logger: false,
264+
root: SUBDOMAIN_DATA_DIR,
265+
idp: true,
266+
idpIssuer: baseUrl,
267+
subdomains: true,
268+
baseDomain: TEST_HOST,
269+
forceCloseConnections: true,
270+
});
271+
272+
await server.listen({ port, host: TEST_HOST });
273+
});
274+
275+
after(async () => {
276+
await server.close();
277+
await fs.remove(SUBDOMAIN_DATA_DIR);
278+
});
279+
280+
async function tryRegister(username) {
281+
const res = await fetch(`${baseUrl}/idp/register`, {
282+
method: 'POST',
283+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
284+
body: new URLSearchParams({ username, password: 'secret-password', confirmPassword: 'secret-password' }),
285+
});
286+
return { status: res.status, body: await res.text() };
287+
}
288+
289+
it('accepts dash (alice-smith)', async () => {
290+
const r = await tryRegister('alice-smith');
291+
assert.match(r.body, /Account created/);
292+
});
293+
294+
it('rejects dot (alice.smith) — would not be a single-level subdomain', async () => {
295+
const r = await tryRegister('alice.smith');
296+
assert.match(r.body, /subdomain mode disallows/);
297+
});
298+
299+
it('rejects underscore (alice_work) — invalid in DNS hostnames', async () => {
300+
const r = await tryRegister('alice_work');
301+
assert.match(r.body, /subdomain mode disallows/);
302+
});
184303
});
185304

186305
describe('Identity Provider - Accounts', () => {

0 commit comments

Comments
 (0)