Skip to content

Commit c9d2b81

Browse files
committed
Self-review: close the account oracle, cache the roster, use showProfiles
The profile endpoint answered 200 for a name with a website account and 404 for one without, whenever the name had never played — one request per name, no credential needed. That is the enumeration oracle closed in #105, rebuilt in a different route. isRegistered is consulted only when the viewer is already authenticated as that name, which keeps the case it was there for: someone who registered on a server that has never run can still see their own profile. getPlayers NBT-parses every player .dat in the world. Every other public route answers from memory, and the per-address budget allows ten requests a second, so one visitor could have the whole world folder parsed ten times a second without signing in. Cached for ten seconds, shared across callers. The client made it worse: refreshWhoami re-fetched whenever puuid was empty, which it stays for a player who registered but never joined, so every navigation fired another roster parse. It tries once per page load now. showProfiles was added to the public payload and never read, so with no server behind the site the header linked a visitor to a page that says "no such player" about themselves.
1 parent eb16f91 commit c9d2b81

3 files changed

Lines changed: 84 additions & 18 deletions

File tree

‎src/main/smoke.ts‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4521,20 +4521,37 @@ export async function runWebSmoke(): Promise<void> {
45214521
if (own.mcName !== 'Profiley') return fail('own profile returned the wrong player')
45224522
if (own.hidden.length !== 0) return fail('a player was told their own data was withheld')
45234523

4524-
// A stranger reading the same name, with everything off.
4524+
// ...and a STRANGER asking about that same name gets the same answer
4525+
// they would get for a name nobody has ever heard of. The fixture
4526+
// server has no roster, so `Profiley` exists only as a website account
4527+
// — and answering 200 for it while answering 404 for `Nobodyy` would
4528+
// make this endpoint report which names have accounts, one per request.
4529+
// That is the oracle closed in #105, in a different route.
4530+
const strangerHit = await sget('/api/public/profile?name=Profiley')
4531+
const strangerMiss = await sget('/api/public/profile?name=Nobodyy')
4532+
if (strangerHit.status !== strangerMiss.status) {
4533+
return fail(
4534+
'a stranger can tell a registered name from an unknown one: ' +
4535+
strangerHit.status + ' vs ' + strangerMiss.status
4536+
)
4537+
}
4538+
// Whatever a stranger does get back must carry none of the gated fields.
45254539
pr = await sget('/api/public/profile?name=Profiley')
4526-
if (pr.status !== 200) return fail('a public profile expected 200, got ' + pr.status)
4527-
const strange = (await pr.json()) as unknown as Record<string, unknown>
4528-
for (const f of GATED) {
4529-
if (f in strange) return fail('an anonymous profile read carried "' + f + '"')
4540+
if (pr.status === 200) {
4541+
const strange = (await pr.json()) as unknown as Record<string, unknown>
4542+
for (const f of GATED) {
4543+
if (f in strange) return fail('an anonymous profile read carried "' + f + '"')
4544+
}
45304545
}
45314546
// An admin token must not be a player token here either: the endpoint
45324547
// decides "owner" from a PLAYER session, and an operator holding a panel
45334548
// token is a stranger to every player account.
45344549
pr = await sget('/api/public/profile?name=Profiley', ot)
4535-
const asAdmin = (await pr.json()) as unknown as Record<string, unknown>
4536-
for (const f of GATED) {
4537-
if (f in asAdmin) return fail('an admin token read a player\'s ' + f + ' from the public site')
4550+
if (pr.status === 200) {
4551+
const asAdmin = (await pr.json()) as unknown as Record<string, unknown>
4552+
for (const f of GATED) {
4553+
if (f in asAdmin) return fail('an admin token read a player\'s ' + f + ' from the public site')
4554+
}
45384555
}
45394556
console.log('WEB-SMOKE: public profile OK (own vs stranger, admin token is a stranger, 400 on a bad name)')
45404557
} finally {

‎src/main/web/publicSiteHtml.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -268,10 +268,15 @@ var S=null,LANG='en',ptoken=localStorage.getItem('msms_ptoken')||'',pname=localS
268268
/* The signed-in player's uuid, for the head beside their name (#107). Cached in
269269
localStorage rather than re-fetched on every render: it never changes for a
270270
given name, and the profile read parses player .dat files. */
271-
var puuid=localStorage.getItem('msms_puuid')||'';
271+
var puuid=localStorage.getItem('msms_puuid')||'',whoamiTried=false;
272272
function refreshWhoami(){
273-
if(!ptoken){puuid='';localStorage.removeItem('msms_puuid');return}
274-
if(puuid)return;
273+
if(!ptoken){puuid='';whoamiTried=false;localStorage.removeItem('msms_puuid');return}
274+
if(puuid||whoamiTried)return;
275+
/* Once per page load, not once per render. A player who has registered but
276+
never joined has no uuid to find, so without the flag every navigation
277+
would fire another request — and on the server side that request is the
278+
only public one that parses the world's player files. */
279+
whoamiTried=true;
275280
api('/api/public/profile',null,ptoken).then(function(r){
276281
if(!r.ok||!r.j.uuid)return;
277282
puuid=r.j.uuid;localStorage.setItem('msms_puuid',puuid);renderChrome()})}
@@ -310,7 +315,12 @@ function renderChrome(){
310315
var sel=document.getElementById('langSel');var codes=Object.keys(S.i18n.langs);
311316
sel.innerHTML=codes.map(function(c){return '<option value="'+c+'"'+(c===LANG?' selected':'')+'>'+c.toUpperCase()+'</option>'}).join('');
312317
document.getElementById('accBtn').innerHTML=ptoken
313-
? '<a href="#/profile" class="whoami" title="'+escAttr(T('profile.title'))+'">'+headImg(puuid,24)+'<span>'+esc(pname)+'</span></a><button class="btn sm" onclick="plogout()">'+esc(T('auth.logout'))+'</button>'
318+
? (S.showProfiles
319+
? '<a href="#/profile" class="whoami" title="'+escAttr(T('profile.title'))+'">'+headImg(puuid,24)+'<span>'+esc(pname)+'</span></a>'
320+
/* No server behind the site means no profile to link to: the page would
321+
load and say "no such player" about the visitor themselves. */
322+
: '<span class="muted" style="margin-right:10px;font-size:14px">'+esc(pname)+'</span>')+
323+
'<button class="btn sm" onclick="plogout()">'+esc(T('auth.logout'))+'</button>'
314324
: '<button class="btn sm primary" onclick="openAuth()">'+esc(T('auth.login'))+'</button>';
315325
var links=[['#/','nav.home'],['#/news','nav.news']];
316326
if(S.showStore)links.push(['#/store','nav.store']);
@@ -548,8 +558,8 @@ function render(){
548558
else if(h==='#/store')app.innerHTML=pageStore();
549559
else if(h==='#/servers')app.innerHTML=pageServers();
550560
else if(h==='#/map'&&S.showMap)app.innerHTML=pageMap();
551-
else if(h.indexOf('#/player/')===0)app.innerHTML=pageProfile(decodeURIComponent(h.slice(9)));
552-
else if(h==='#/profile'&&ptoken)app.innerHTML=pageProfile('');
561+
else if(h.indexOf('#/player/')===0&&S.showProfiles)app.innerHTML=pageProfile(decodeURIComponent(h.slice(9)));
562+
else if(h==='#/profile'&&ptoken&&S.showProfiles)app.innerHTML=pageProfile('');
553563
else app.innerHTML=pageHome();
554564
/* Stop the 2s feed on the way OUT of the map. Without this a visitor who
555565
opened it once keeps polling for as long as the tab is open, from every

‎src/main/web/server.ts‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,34 @@ import type { ProfileViewer } from '@shared/profile'
3838

3939
/** Same shape the rest of the app validates a Minecraft name with. */
4040
const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/
41+
42+
/**
43+
* The player roster, cached for a few seconds (#107).
44+
*
45+
* `getPlayers` parses every player's `.dat` with an NBT reader. Every other
46+
* public route answers from memory; this one is the first that reads the disk,
47+
* and the per-address budget allows ten requests a second — so without a cache
48+
* one visitor turns a cheap HTTP request into the whole world folder being
49+
* parsed, ten times a second, and a busy server has thousands of those files.
50+
*
51+
* The window is short because a profile showing a ten-second-old inventory is
52+
* fine and one that pins the process is not. Shared across callers rather than
53+
* per-address: the expensive part is the same work whoever asked for it.
54+
*/
55+
const rosterCache = new Map<string, { at: number; players: PlayerInfo[] }>()
56+
const ROSTER_TTL_MS = 10_000
57+
58+
async function cachedRoster(serverId: string): Promise<PlayerInfo[]> {
59+
const hit = rosterCache.get(serverId)
60+
if (hit && Date.now() - hit.at < ROSTER_TTL_MS) return hit.players
61+
const players = await playersMod.getPlayers(serverId).catch(() => [] as PlayerInfo[])
62+
rosterCache.set(serverId, { at: Date.now(), players })
63+
return players
64+
}
65+
66+
export function _resetRosterCache(): void {
67+
rosterCache.clear()
68+
}
4169
import * as metrics from '../core/metrics'
4270
import * as events from '../core/events'
4371
import * as alerts from '../core/alerts'
@@ -527,6 +555,8 @@ async function handlePublic(
527555

528556
// ---- a player's profile (#107) ----
529557
//
558+
// See `cachedRoster`: this is the only public route that touches the disk.
559+
//
530560
// Who is asking decides what comes back, and the decision is a pure table in
531561
// @shared/profile because it is the whole security of the feature. Fields are
532562
// OMITTED, never sent-and-hidden: a page can be read with the network tab
@@ -543,11 +573,20 @@ async function handlePublic(
543573
: session.mcName.toLowerCase() === asked.toLowerCase()
544574
? 'owner'
545575
: 'stranger'
546-
// The roster read is the expensive part (it parses player .dat files), so
547-
// it happens once and the decision runs over the result.
548-
const roster = await playersMod.getPlayers(psid).catch(() => [])
576+
const roster = await cachedRoster(psid)
549577
const p = roster.find((x) => x.name.toLowerCase() === asked.toLowerCase())
550-
if (!p && !playerAuth.isRegistered(asked)) return sendJson(res, 404, { error: 'not-found' })
578+
// Existence is decided by the ROSTER alone for anyone but the owner.
579+
//
580+
// Consulting `isRegistered` here for a stranger would make 200-vs-404
581+
// answer "does this name have a website account?" for every name that has
582+
// never played — the same enumeration oracle closed in #105, reopened in a
583+
// different endpoint. The owner is already authenticated as that name, so
584+
// telling them their own account exists reveals nothing, and it is the only
585+
// way someone who registered on a server that has never run can see their
586+
// own profile.
587+
if (!p && !(viewer === 'owner' && playerAuth.isRegistered(asked))) {
588+
return sendJson(res, 404, { error: 'not-found' })
589+
}
551590
return sendJson(
552591
res,
553592
200,

0 commit comments

Comments
 (0)