@xgamesfun/xcore 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -16
- package/commands/dev.mjs +31 -10
- package/commands/login.mjs +22 -11
- package/commands/logout.mjs +2 -0
- package/docs/SERVER.md +90 -22
- package/lib/check.mjs +1 -0
- package/lib/cli.mjs +2 -0
- package/lib/client/gamesfun.js +4 -1
- package/lib/client/server/0001_init.sql +1 -1
- package/lib/client/server/database.js +2 -1
- package/lib/client/server/gf-db.js +6 -2
- package/lib/client/server/rooms.js +1 -0
- package/lib/dev/files.mjs +102 -5
- package/lib/dev/gf-local.js +4 -0
- package/lib/dev/index.mjs +32 -9
- package/lib/dev/limits.mjs +55 -24
- package/lib/dev/plans.mjs +101 -0
- package/lib/dev/platform.mjs +485 -136
- package/lib/login-reach.mjs +184 -0
- package/lib/publish/index.mjs +16 -7
- package/lib/publish/names.mjs +128 -1
- package/lib/publish/scan.mjs +25 -1
- package/lib/publish/tree.mjs +9 -1
- package/lib/review.mjs +216 -1
- package/lib/secret-patterns.mjs +36 -26
- package/lib/server/bundle.mjs +139 -28
- package/lib/server/contract.mjs +91 -11
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +13 -4
package/lib/review.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// the server this package's own static reading of server/ and the page: PAY.check written or still the template,
|
|
7
7
|
// one that pays from the request alone, VOUCHERS.payOnly against the grants, the voucher key and the
|
|
8
8
|
// grants that name it, `trust` on each voucher grant, .dev.vars in git or in the published folder, a
|
|
9
|
-
// seed in the page's files,
|
|
9
|
+
// seed in the page's files, a server of the game's own that does work before (or without) checking a
|
|
10
|
+
// player's token, and every api/ route the code names (for the live part).
|
|
10
11
|
// live (--live) each route asked with no sign-in and with a forged one, GET and POST; api/vouchers/key
|
|
11
12
|
// against the grants' keys; the published economy file's `live` against this folder's. Read-only:
|
|
12
13
|
// nothing it sends could spend or sign (a route that pays gets an empty body).
|
|
@@ -271,6 +272,217 @@ function gitState(dir, rel) {
|
|
|
271
272
|
}
|
|
272
273
|
|
|
273
274
|
const TEXT_CODE = /\.(m?js|cjs|jsx|tsx?|html?|svelte|vue|astro)$/i;
|
|
275
|
+
|
|
276
|
+
// ---- Work done before a token is checked ----------------------------------------------------------------------------
|
|
277
|
+
// A request with no good token is a stranger's, and the work a server does for strangers counts toward what caps the
|
|
278
|
+
// game (a capped game's server is switched off until the month ends). So: check the token before any work. Read simply,
|
|
279
|
+
// to say nothing that is not so, over the game's own server files (not the parts xcore writes, which check where they
|
|
280
|
+
// must or are public by design), with comments and strings left out:
|
|
281
|
+
// work a call to the platform's saves, scores, files or loot (env.GF.saves.get, env.GF?.saves?.get, through
|
|
282
|
+
// `const g = env.GF` or `const { GF } = env` too), a database, a Durable Object, or a fetch() out;
|
|
283
|
+
// a check env.GF.verifyPlayer, gf-db.js's withPlayer or a JWT library's jwtVerify, or a call to a function of the
|
|
284
|
+
// game's own that makes one (in any of its own files, through other functions of its own);
|
|
285
|
+
// not decided: a binding from a package whose name says it checks (a router's jwt or auth middleware): nothing is said.
|
|
286
|
+
// Named when the game's own files do work and make no check anywhere (medium), and when the entry's fetch does work
|
|
287
|
+
// before its first check (low: a public read there is fine, and the person decides), unless the only work is waking a
|
|
288
|
+
// Durable Object whose class checks the token itself.
|
|
289
|
+
/** The files xcore writes into server/ (lib/add.mjs), each checking a token where it must or public by design. */
|
|
290
|
+
const XCORE_PARTS = new Set(['database.js', 'gf-docs.js', 'gf-db.js', 'rooms.js', 'rpc.js', 'vouchers.js']);
|
|
291
|
+
const TOKEN_CHECK = /(?<![\w$])(?:verifyPlayer|withPlayer|jwtVerify)\s*(?:\?\.\s*)?\(/;
|
|
292
|
+
const DOT = '\\s*\\??\\.\\s*';
|
|
293
|
+
const CALL = '\\s*(?:\\?\\.\\s*)?\\(';
|
|
294
|
+
/** The kinds of work, as patterns over code whose platform binding may also be called `gf` (its aliases). */
|
|
295
|
+
const workPatterns = gf => [
|
|
296
|
+
['saves', new RegExp(`(?<![\\w$])(?:${gf})${DOT}saves${DOT}[\\w$]+${CALL}`)],
|
|
297
|
+
['scores', new RegExp(`(?<![\\w$])(?:${gf})${DOT}scores${DOT}[\\w$]+${CALL}`)],
|
|
298
|
+
['files', new RegExp(`(?<![\\w$])(?:${gf})${DOT}files${DOT}[\\w$]+${CALL}`)],
|
|
299
|
+
['loot', new RegExp(`(?<![\\w$])(?:${gf})${DOT}loot${DOT}[\\w$]+${CALL}`)],
|
|
300
|
+
[
|
|
301
|
+
'a database',
|
|
302
|
+
new RegExp(
|
|
303
|
+
`\\benv${DOT}[A-Z][A-Z0-9_]*${DOT}(?:prepare|batch|exec|dump)${CALL}|\\b(?:db|docsOf|dataOf)\\s*\\(\\s*env\\s*\\)`,
|
|
304
|
+
),
|
|
305
|
+
],
|
|
306
|
+
[
|
|
307
|
+
'a Durable Object',
|
|
308
|
+
new RegExp(`\\benv${DOT}[A-Z][A-Z0-9_]*${DOT}(?:get|idFromName|idFromString|newUniqueId|getByName)${CALL}`),
|
|
309
|
+
],
|
|
310
|
+
['a fetch out', /(?<![\w$.])fetch\s*\(/],
|
|
311
|
+
];
|
|
312
|
+
/** Code with every string and template literal blanked out (lengths and lines kept): a word there is never a call. */
|
|
313
|
+
export function withoutStrings(code) {
|
|
314
|
+
let out = '';
|
|
315
|
+
for (let i = 0; i < code.length;) {
|
|
316
|
+
const c = code[i];
|
|
317
|
+
if (c !== '"' && c !== "'" && c !== '`') {
|
|
318
|
+
out += c;
|
|
319
|
+
i++;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
let j = i + 1;
|
|
323
|
+
while (j < code.length && code[j] !== c && !(c !== '`' && code[j] === '\n')) j += code[j] === '\\' ? 2 : 1;
|
|
324
|
+
out += c + code.slice(i + 1, j).replace(/[^\n]/g, ' ') + (j < code.length ? c : '');
|
|
325
|
+
i = j + 1;
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
/** A method's own `fetch(…) {` taken out, so only calls remain. */
|
|
330
|
+
const withoutFetchHandlers = code =>
|
|
331
|
+
code.replace(/\b(?:async\s+)?fetch\s*(?::\s*(?:async\s*)?(?:function\s*)?)?\(([^()]*)\)\s*(?:=>\s*)?\{/g, m =>
|
|
332
|
+
' '.repeat(m.length),
|
|
333
|
+
);
|
|
334
|
+
/** The names `env.GF` goes by in `code`: GF, and `const g = env.GF` aliases. → a pattern's alternatives */
|
|
335
|
+
const gfNames = code =>
|
|
336
|
+
[
|
|
337
|
+
'GF',
|
|
338
|
+
...[
|
|
339
|
+
...code.matchAll(
|
|
340
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:this\s*\.\s*)?env\s*\??\.\s*GF\b(?!\s*\??\.)/g,
|
|
341
|
+
),
|
|
342
|
+
].map(m => m[1]),
|
|
343
|
+
]
|
|
344
|
+
.map(n => n.replace(/\$/g, '\\$'))
|
|
345
|
+
.join('|');
|
|
346
|
+
/** The kinds of work `code` does, and where the first one is. → {kinds: [word], at} */
|
|
347
|
+
function workIn(code, gf = 'GF') {
|
|
348
|
+
const c = withoutFetchHandlers(code);
|
|
349
|
+
const hits = workPatterns(gf)
|
|
350
|
+
.map(([what, re]) => [what, re.exec(c)?.index ?? -1])
|
|
351
|
+
.filter(([, at]) => at >= 0);
|
|
352
|
+
return { kinds: hits.map(([what]) => what), at: hits.length ? Math.min(...hits.map(([, at]) => at)) : -1 };
|
|
353
|
+
}
|
|
354
|
+
/** The functions (and methods) `code` defines: [{name, body}]. A body is its braces, or an arrow's line. */
|
|
355
|
+
function definitions(code) {
|
|
356
|
+
const out = [];
|
|
357
|
+
const add = (name, from) => {
|
|
358
|
+
const brace = code.indexOf('{', from);
|
|
359
|
+
const eol = code.indexOf('\n', from);
|
|
360
|
+
const body =
|
|
361
|
+
brace >= 0 && (eol < 0 || brace < eol)
|
|
362
|
+
? (braced(code, brace) ?? '')
|
|
363
|
+
: code.slice(from, eol < 0 ? undefined : eol);
|
|
364
|
+
out.push({ name, body });
|
|
365
|
+
};
|
|
366
|
+
for (const m of code.matchAll(/\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g)) add(m[1], m.index + m[0].length);
|
|
367
|
+
for (const m of code.matchAll(
|
|
368
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\b|function\b|\(|[A-Za-z_$][\w$]*\s*=>)/g,
|
|
369
|
+
))
|
|
370
|
+
add(m[1], m.index + m[0].length);
|
|
371
|
+
// methods of a class or an object literal (wherever they start: { async check(…) { … } }), and an object's
|
|
372
|
+
// arrow or function properties (check: async (…) => { … })
|
|
373
|
+
for (const m of code.matchAll(/(?:^|[{},;\s])(?:static\s+)?(?:async\s+)?\*?\s*([A-Za-z_$][\w$]*)\s*\([^()]*\)\s*\{/g))
|
|
374
|
+
if (!/^(?:if|for|while|switch|catch|function|return|fetch|with)$/.test(m[1])) add(m[1], m.index + m[0].length - 1);
|
|
375
|
+
for (const m of code.matchAll(
|
|
376
|
+
/(?:^|[{,\s])([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?(?:function\b[^(]*\(|\(|[A-Za-z_$][\w$]*\s*=>)/g,
|
|
377
|
+
))
|
|
378
|
+
if (m[1] !== 'fetch') add(m[1], m.index + m[0].length);
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
/** The names of the game's own functions that check a token, directly or through another of them. */
|
|
382
|
+
function checkingFunctions(codes) {
|
|
383
|
+
const defs = codes.flatMap(definitions);
|
|
384
|
+
const names = new Set(defs.filter(d => TOKEN_CHECK.test(d.body)).map(d => d.name));
|
|
385
|
+
for (let changed = true; changed;) {
|
|
386
|
+
changed = false;
|
|
387
|
+
for (const d of defs)
|
|
388
|
+
if (!names.has(d.name) && [...names].some(n => callOf(n).test(d.body))) {
|
|
389
|
+
names.add(d.name);
|
|
390
|
+
changed = true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return names;
|
|
394
|
+
}
|
|
395
|
+
const callOf = name => new RegExp(`(?<![\\w$])${name.replace(/\$/g, '\\$')}\\s*(?:\\?\\.\\s*)?\\(`);
|
|
396
|
+
/** Where the first check is in `code`: a token check, or a call to a checking function. → index or -1 */
|
|
397
|
+
function firstCheck(code, checking) {
|
|
398
|
+
const at = [TOKEN_CHECK, ...[...checking].map(callOf)].map(re => re.exec(code)?.index ?? -1).filter(i => i >= 0);
|
|
399
|
+
return at.length ? Math.min(...at) : -1;
|
|
400
|
+
}
|
|
401
|
+
/** The default export's fetch body in comment-free code, or null. */
|
|
402
|
+
function fetchBody(code) {
|
|
403
|
+
const start = /export\s+default\s*\{/.exec(code);
|
|
404
|
+
if (!start) return null;
|
|
405
|
+
const obj = braced(code, start.index + start[0].length - 1);
|
|
406
|
+
if (!obj) return null;
|
|
407
|
+
const m = /\b(?:async\s+)?fetch\s*(?::\s*(?:async\s*)?(?:function\s*)?)?\(([^()]*)\)\s*(?:=>\s*)?\{/.exec(obj);
|
|
408
|
+
return m ? braced(obj, m.index + m[0].length - 1) : null;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* An import from a package that says it checks a sign-in (a router's jwt or auth middleware): a whole word of the
|
|
412
|
+
* package's name or path (auth, @scope/auth, x-auth, auth-kit, hono/jwt) or of a name it imports (jwt, requireAuth,
|
|
413
|
+
* verifyToken), never a word that only has one inside it (authority, authorName).
|
|
414
|
+
*/
|
|
415
|
+
const PACKAGE_IMPORT =
|
|
416
|
+
/\bimport\s+(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\}|\*\s*as\s+[A-Za-z_$][\w$]*)?\s*from\s*['"]((?![./])[^'"]+)['"]/g;
|
|
417
|
+
const SIGN_IN_WORDS = new Set([
|
|
418
|
+
'auth',
|
|
419
|
+
'oauth',
|
|
420
|
+
'jwt',
|
|
421
|
+
'jose',
|
|
422
|
+
'bearer',
|
|
423
|
+
'session',
|
|
424
|
+
'verify',
|
|
425
|
+
'authenticate',
|
|
426
|
+
'authorize',
|
|
427
|
+
]);
|
|
428
|
+
/** A name's words: split at - _ . / @ and at CamelCase, lower case. */
|
|
429
|
+
const wordsOfName = s =>
|
|
430
|
+
String(s)
|
|
431
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
432
|
+
.toLowerCase()
|
|
433
|
+
.split(/[^a-z0-9]+/)
|
|
434
|
+
.filter(Boolean);
|
|
435
|
+
const packageChecks = code =>
|
|
436
|
+
[...code.matchAll(PACKAGE_IMPORT)].some(m =>
|
|
437
|
+
[m[3], m[1] ?? '', ...(m[2] ?? '').split(',').map(x => x.trim().split(/\s+as\s+/)[0])].some(n =>
|
|
438
|
+
wordsOfName(n).some(w => SIGN_IN_WORDS.has(w)),
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
const TOKEN_WHY =
|
|
442
|
+
"The work a server does for a caller with no token counts toward what caps the game, and a capped game's server is switched off until the month ends.";
|
|
443
|
+
const TOKEN_FIX =
|
|
444
|
+
'Check the game token before any work: answer 401 at once when the request has none, then const player = await env.GF.verifyPlayer(token) (it checks the token\'s signature, its audience and its expiry) and 401 when it is not ok; keep only what is truly public (a leaderboard read) before it (docs/SERVER.md, "Check the token first").';
|
|
445
|
+
|
|
446
|
+
/** The findings for work done without, or before, a token check (see above). `files`: [{f, code (comments out), text}]. */
|
|
447
|
+
function tokenFindings(dir, files, add) {
|
|
448
|
+
const own = files
|
|
449
|
+
.filter(({ f, text }) => !XCORE_PARTS.has(f) && !(f === 'index.js' && /Written by `xcore add/.test(text)))
|
|
450
|
+
.map(x => ({ ...x, bare: withoutStrings(x.code) }));
|
|
451
|
+
if (!own.length || own.some(x => packageChecks(x.code))) return; // not decided: nothing said
|
|
452
|
+
const working = own.map(x => ({ ...x, work: workIn(x.bare, gfNames(x.bare)) })).filter(x => x.work.kinds.length);
|
|
453
|
+
if (!working.length) return;
|
|
454
|
+
const checking = checkingFunctions(own.map(x => x.bare));
|
|
455
|
+
if (!own.some(x => TOKEN_CHECK.test(x.bare))) {
|
|
456
|
+
const names = working.map(x => `server/${x.f}`);
|
|
457
|
+
const kinds = [...new Set(working.flatMap(x => x.work.kinds))];
|
|
458
|
+
add(
|
|
459
|
+
'server-no-token-check',
|
|
460
|
+
'medium',
|
|
461
|
+
`${names.join(', ')} ${names.length === 1 ? 'does' : 'do'} work for every request (${kinds.join(', ')}) and never check${names.length === 1 ? 's' : ''} a player's token: anyone, signed in or not, can make it spend the game's allowance. ${TOKEN_WHY}`,
|
|
462
|
+
TOKEN_FIX,
|
|
463
|
+
);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// the entry's fetch: work before its first check
|
|
467
|
+
const cfg = readJson(path.join(dir, 'gamesfun.json'));
|
|
468
|
+
const main = typeof cfg?.server?.main === 'string' ? cfg.server.main.replace(/^\.\//, '') : 'server/index.js';
|
|
469
|
+
const entry = own.find(x => `server/${x.f}` === main);
|
|
470
|
+
const body = entry ? fetchBody(entry.bare) : null;
|
|
471
|
+
if (!body) return;
|
|
472
|
+
const work = workIn(body, gfNames(entry.bare));
|
|
473
|
+
if (work.at < 0) return;
|
|
474
|
+
const check = firstCheck(body, checking);
|
|
475
|
+
if (check >= 0 && check < work.at) return;
|
|
476
|
+
// only a room woken, and the room's class checks the token itself: the room turns the stranger away
|
|
477
|
+
const rooms = own.filter(x => /\bextends\s+DurableObject\b/.test(x.bare));
|
|
478
|
+
if (work.kinds.every(k => k === 'a Durable Object') && rooms.some(x => firstCheck(x.bare, checking) >= 0)) return;
|
|
479
|
+
add(
|
|
480
|
+
'work-before-token',
|
|
481
|
+
'low',
|
|
482
|
+
`${main}'s fetch does work (${work.kinds.join(', ')}) before it checks a player's token${check < 0 ? ' (it checks none there itself)' : ''}: fine for what is truly public (a leaderboard read), but every stranger's request runs it. ${TOKEN_WHY}`,
|
|
483
|
+
TOKEN_FIX,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
274
486
|
const OWN_COPIES = ['gf', 'gamesfun.js']; // xcore's and loot's copies in the page: not the game's own routes
|
|
275
487
|
|
|
276
488
|
/**
|
|
@@ -291,11 +503,14 @@ export async function serverReview(dir, { spec = null, economy = null, available
|
|
|
291
503
|
TEXT_CODE.test(f),
|
|
292
504
|
);
|
|
293
505
|
let serverCode = '';
|
|
506
|
+
const read = [];
|
|
294
507
|
for (const f of serverFiles) {
|
|
295
508
|
const text = await readFile(path.join(dir, 'server', f), 'utf8');
|
|
296
509
|
serverCode += `\n${withoutComments(text)}`;
|
|
510
|
+
read.push({ f, code: withoutComments(text), text });
|
|
297
511
|
for (const r of apiRoutes(text)) named(r, `server/${f}`);
|
|
298
512
|
}
|
|
513
|
+
tokenFindings(dir, read, add);
|
|
299
514
|
// A token checked by hand (a JWT library against <play host>/player/v1/key): every game's tokens are signed with the
|
|
300
515
|
// one key, so a check that skips the audience takes a token any other game was given (a game a player signed in to,
|
|
301
516
|
// whose creator then replays the token here). env.GF.verifyPlayer checks it; a hand-made check must name it.
|
package/lib/secret-patterns.mjs
CHANGED
|
@@ -1,58 +1,67 @@
|
|
|
1
1
|
// Key-shaped strings: the one list of them. The publish scan (./publish/scan.mjs) reads every pattern here, and the
|
|
2
2
|
// kit imports SECRET_PATTERNS for its own scanner. Pure: no imports.
|
|
3
3
|
//
|
|
4
|
-
// Every
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// Every pattern runs in time linear in the file, as xgames.fun's own scan does: every run has an upper bound, nothing
|
|
5
|
+
// optional repeats inside a repeat, a look-behind reads a few characters at most, and a pattern that starts inside a run
|
|
6
|
+
// of the characters it then reads (a JWT, an RPC URL's host) starts only where that run begins, so a long run is read once
|
|
7
|
+
// rather than once for each place it could start (tests/scan.test.mjs times each on 4 MiB of text written to hold it
|
|
8
|
+
// up). No match is longer than MAX_MATCH characters, so the scan reads a big file in pieces that overlap by more than
|
|
9
|
+
// that, and finds a key that straddles two pieces (tests/scan.test.mjs checks each pattern's longest match against it).
|
|
10
|
+
// Each pattern xgames.fun's own scan has is here as it is there, with the same bounds, so the kit refuses what the
|
|
11
|
+
// host refuses, and nothing the host would publish (tests/scan.test.mjs holds the *_SECRET literal's bounds to it).
|
|
12
|
+
// Three are the kit's own, for the key formats it writes itself (a signing key's seed, a {pub, seed} key file, secret
|
|
13
|
+
// key bytes in code): the host does not read those, and a publish that carries one is refused here first.
|
|
8
14
|
|
|
9
|
-
/** The longest text any pattern here can match (the JWT's:
|
|
10
|
-
export const MAX_MATCH =
|
|
15
|
+
/** The longest text any pattern here can match (the JWT's: 8,192 + 16,384 + 8,192 and its marks). */
|
|
16
|
+
export const MAX_MATCH = 40 * 1024;
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* [kind, pattern]. Each kind is the words a refusal names it by. The last, 'JSON web token', is any JWT: the publish
|
|
14
20
|
* scan reads a JWT's role instead (a Supabase anon key is public by design), and the kit replaces it the same way.
|
|
15
21
|
*/
|
|
16
22
|
export const SECRET_PATTERNS = [
|
|
17
|
-
['private key block', /-----BEGIN (?:[A-Z]{1,
|
|
23
|
+
['private key block', /-----BEGIN (?:[A-Z]{1,32} )?PRIVATE KEY-----/],
|
|
18
24
|
// not inside a word or a class name (style-sk-aria-pressed): a key stands on its own
|
|
19
25
|
[
|
|
20
26
|
'OpenAI or Anthropic key',
|
|
21
|
-
/(?<![A-Za-z0-9_-])sk-(?:(?:proj|ant|svcacct|admin)-[A-Za-z0-9_-]{20,
|
|
27
|
+
/(?<![A-Za-z0-9_-])sk-(?:(?:proj|ant|svcacct|admin)-[A-Za-z0-9_-]{20,256}|[A-Za-z0-9]{32,256})/,
|
|
22
28
|
],
|
|
23
|
-
['Meshy key', /\bmsy_[A-Za-z0-9_-]{16,
|
|
29
|
+
['Meshy key', /\bmsy_[A-Za-z0-9_-]{16,256}/],
|
|
24
30
|
['AWS access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
25
|
-
['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,
|
|
26
|
-
['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,
|
|
31
|
+
['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,256}|github_pat_[A-Za-z0-9_]{30,256})/],
|
|
32
|
+
['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,256}/],
|
|
27
33
|
['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
28
|
-
['Stripe secret key', /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,
|
|
29
|
-
['Supabase secret key', /\bsb_secret_[A-Za-z0-9_-]{16,
|
|
30
|
-
['Supabase access token', /\bsbp_[A-Za-z0-9]{20,
|
|
31
|
-
['database address with a password', /\bpostgres(?:ql)?:\/\/[^\s:@/'"]{1,
|
|
32
|
-
['xgames.fun publish token', /\bgfk_[A-Za-z0-9_-]{30,
|
|
34
|
+
['Stripe secret key', /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,256}/],
|
|
35
|
+
['Supabase secret key', /\bsb_secret_[A-Za-z0-9_-]{16,256}/],
|
|
36
|
+
['Supabase access token', /\bsbp_[A-Za-z0-9]{20,256}/],
|
|
37
|
+
['database address with a password', /\bpostgres(?:ql)?:\/\/[^\s:@/'"]{1,256}:[^\s@/'"]{1,256}@/i],
|
|
38
|
+
['xgames.fun publish token', /\bgfk_[A-Za-z0-9_-]{30,256}/],
|
|
33
39
|
// providers whose keys have a shape of their own (each prefix, length and alphabet as the provider issues them)
|
|
34
|
-
['Hugging Face token', /\bhf_[A-Za-z0-9]{34,
|
|
40
|
+
['Hugging Face token', /\bhf_[A-Za-z0-9]{34,256}\b/],
|
|
35
41
|
['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
|
|
36
|
-
['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,
|
|
42
|
+
['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,256}/],
|
|
37
43
|
['SendGrid key', /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/],
|
|
38
|
-
['Stripe webhook secret', /\bwhsec_[A-Za-z0-9]{32,
|
|
44
|
+
['Stripe webhook secret', /\bwhsec_[A-Za-z0-9]{32,256}/],
|
|
39
45
|
['Shopify token', /\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b/],
|
|
40
46
|
['Google OAuth client secret', /\bGOCSPX-[A-Za-z0-9_-]{28}\b/],
|
|
41
47
|
['DigitalOcean token', /\bdo[opr]_v1_[a-f0-9]{64}\b/],
|
|
42
48
|
['Telegram bot token', /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/],
|
|
43
|
-
// a game's page never holds an RPC URL (it calls its own /loot/v1/rpc): one with a key in it is that key
|
|
49
|
+
// a game's page never holds an RPC URL (it calls its own /loot/v1/rpc): one with a key in it is that key. The host is
|
|
50
|
+
// read only from where it starts (right after the scheme) and up to a DNS name's 253 characters; helius's key is in
|
|
51
|
+
// the query, at most 512 characters along
|
|
44
52
|
[
|
|
45
53
|
'an RPC URL with its key (helius, alchemy, quicknode, infura)',
|
|
46
|
-
/https?:\/\/[
|
|
54
|
+
/https?:\/\/[A-Za-z0-9.-]{0,253}(?:helius-rpc\.com[^\s"'`]{0,512}?[?&]api-key=[A-Za-z0-9-]{16,256}|\.alchemy\.com\/v2\/[A-Za-z0-9_-]{20,256}|\.quiknode\.pro\/[A-Za-z0-9]{20,256}|\.infura\.io\/v3\/[a-f0-9]{32})/,
|
|
47
55
|
],
|
|
48
56
|
[
|
|
49
57
|
'a Solana keypair in code (fromSecretKey)',
|
|
50
|
-
/\bfromSecretKey\s{0,
|
|
58
|
+
/\bfromSecretKey\s{0,16}\(\s{0,16}(?:(?:new\s{1,16}Uint8Array|Uint8Array\.from|bs58\.decode)\s{0,16}\(\s{0,16})?[["'`]/,
|
|
51
59
|
],
|
|
52
60
|
[
|
|
53
61
|
'secret assigned a literal',
|
|
54
|
-
// not the first branch of a ternary (cond ? 'MySecretsText' : 'OtherText')
|
|
55
|
-
|
|
62
|
+
// not the first branch of a ternary (cond ? 'MySecretsText' : 'OtherText'), the spaces looked back over up to 16
|
|
63
|
+
// (unbounded, the look-behind read every space before every place in a file of spaces); the value is its first group
|
|
64
|
+
/(?<!\?\s{0,16}["']?)\b[A-Za-z0-9_]{0,64}(?:SECRET|API_KEY|APIKEY|PRIVATE_KEY|ACCESS_TOKEN|SERVICE_ROLE|PASSWORD)[A-Za-z0-9_]{0,64}["']?\s{0,16}[:=]\s{0,16}["']([A-Za-z0-9+/_=.-]{16,1024})["']/i,
|
|
56
65
|
],
|
|
57
66
|
// a signing key's 32-byte seed in hex, by its name (VOUCHER_SEED=…, voucherSeed: '…'): xcore add vouchers' format
|
|
58
67
|
[
|
|
@@ -71,6 +80,7 @@ export const SECRET_PATTERNS = [
|
|
|
71
80
|
],
|
|
72
81
|
// never touching + / = : those break up base64 data (an inlined picture), where a run of base58 letters is chance
|
|
73
82
|
['Solana secret key (base58)', /(?<![A-Za-z0-9+/])[1-9A-HJ-NP-Za-km-z]{86,90}(?![A-Za-z0-9+/=])/],
|
|
74
|
-
// header, payload (16K: a token with many claims), signature (an RS512 one with a 8192-bit key is 1,366)
|
|
75
|
-
|
|
83
|
+
// header, payload (16K: a token with many claims), signature (an RS512 one with a 8192-bit key is 1,366); it starts
|
|
84
|
+
// where its run of base64url letters starts, never at an "eyJ" inside one
|
|
85
|
+
['JSON web token', /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,8192}\.eyJ[A-Za-z0-9_-]{10,16384}\.[A-Za-z0-9_-]{16,8192}/],
|
|
76
86
|
];
|
package/lib/server/bundle.mjs
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
// into one ES module, and the same checks run for the kit's dev server, `xcore publish --check` and the publish itself:
|
|
3
3
|
// - it builds (an import that does not resolve, a syntax error: esbuild's own words, with the file and line);
|
|
4
4
|
// - every file it reads is inside the game folder (a game's own node_modules counts), never the kit or the machine;
|
|
5
|
-
// - Node built-ins
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// - no Node built-ins (xgames.fun offers no nodejs_compat: contract.mjs ALLOWED_FLAGS), named in words;
|
|
6
|
+
// - what xgames.fun refuses in a bundle: an import, a re-export or an import() of a module that opens raw sockets or
|
|
7
|
+
// loads modules by name (contract.mjs SOCKET_MODULES), and an import() of anything but a plain string, which could
|
|
8
|
+
// name any module at run time. The verdict is the bundle's, as xgames.fun reads the bundle it is sent: the bundle
|
|
9
|
+
// the kit would send is read again by esbuild's own parser (bundleImports; never by searching its text), so code
|
|
10
|
+
// esbuild leaves out (a package's export the game never uses) refuses nothing. Each refusal names the file that
|
|
11
|
+
// has the import, as esbuild saw it while bundling;
|
|
12
|
+
// - no code made from text at run time: eval, new Function, importScripts (a Worker refuses them anyway; here the
|
|
13
|
+
// person reads where);
|
|
9
14
|
// - no secrets: the publish scan (../publish/scan.mjs) over each server file and over the bundle;
|
|
10
15
|
// - a default export (the Worker), and each room class gamesfun.json declares exported by that name;
|
|
11
16
|
// - the size: xgames.fun's cap (5 MB, the wasm included), with a word above 1 MB;
|
|
@@ -25,11 +30,11 @@ import { codeOnly, dataCallLine, ownerlessDataCalls } from './data-calls.mjs';
|
|
|
25
30
|
import {
|
|
26
31
|
CONFIG_FILE,
|
|
27
32
|
LIMITS,
|
|
28
|
-
NODE_REFUSED,
|
|
29
33
|
OLD_DEFAULT_RULES,
|
|
30
34
|
OWNER_SPACES,
|
|
31
35
|
readServerConfig,
|
|
32
36
|
BUNDLE_NAME,
|
|
37
|
+
socketModule,
|
|
33
38
|
usesFiles,
|
|
34
39
|
} from './contract.mjs';
|
|
35
40
|
|
|
@@ -79,15 +84,86 @@ const NODE_BUILTINS = new Set([
|
|
|
79
84
|
'worker_threads',
|
|
80
85
|
'zlib',
|
|
81
86
|
]);
|
|
82
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Code made from text at run time: [what, pattern]. Matched in each file the bundle reads. (An import() of a name made
|
|
89
|
+
* at run time is read by the parse instead: refusedImports below.)
|
|
90
|
+
*/
|
|
83
91
|
export const FORBIDDEN = [
|
|
84
92
|
['eval()', /(?<![.\w$])eval\s*\(/],
|
|
85
93
|
['new Function()', /\bnew\s+Function\s*\(/],
|
|
86
|
-
['a dynamic import() of a computed path', /(?<![.\w$])import\s*\(\s*(?!['"`]|\))/],
|
|
87
|
-
// a string built at run time: import('./' + name), import(`./${name}`)
|
|
88
|
-
['a dynamic import() of a computed path', /(?<![.\w$])import\s*\(\s*(?:(['"])[^'"\n]*\1\s*\+|`[^`]*\$\{)/],
|
|
89
94
|
['importScripts()', /(?<![.\w$])importScripts\s*\(/],
|
|
90
95
|
];
|
|
96
|
+
/** esbuild's word for an import() it cannot bundle because its argument is not a plain string. */
|
|
97
|
+
const COMPUTED_IMPORT = 'unsupported-dynamic-import';
|
|
98
|
+
/** A string literal in printed code: "…" or '…' (never a template: the host's parse reads only these as plain). */
|
|
99
|
+
const STRING = /^\s*("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')\s*,/;
|
|
100
|
+
const unquote = q => {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(q[0] === '"' ? q : `"${q.slice(1, -1).replace(/\\'/g, "'").replace(/"/g, '\\"')}"`);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* An import() esbuild did not bundle (its warning, `w`), read at its place: → {module} when its first argument is a
|
|
109
|
+
* plain string after all (an import() with a second argument esbuild does not take, such as `{}`), else {computed}.
|
|
110
|
+
*/
|
|
111
|
+
function unbundled(w) {
|
|
112
|
+
const text = w.location?.lineText ?? '',
|
|
113
|
+
col = w.location?.column ?? 0;
|
|
114
|
+
const at = text.slice(col).startsWith('import') ? col : text.lastIndexOf('import', col);
|
|
115
|
+
const m = at >= 0 ? /^import\s*\(/.exec(text.slice(at)) : null;
|
|
116
|
+
const lit = m ? STRING.exec(text.slice(at + m[0].length)) : null;
|
|
117
|
+
const value = lit ? unquote(lit[1]) : null;
|
|
118
|
+
return typeof value === 'string' ? { module: value } : { computed: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The imports a bundle (its bytes) still has, read by esbuild's parser as xgames.fun's parse reads them: → {modules:
|
|
123
|
+
* Set of what is imported (statically, as a re-export or by import() of a plain string, as written), computed: the
|
|
124
|
+
* number of import() calls of anything else}. null when it cannot be read (then nothing is taken off what the build saw).
|
|
125
|
+
*/
|
|
126
|
+
export async function bundleImports(build, code) {
|
|
127
|
+
const modules = new Set();
|
|
128
|
+
let computed = 0;
|
|
129
|
+
try {
|
|
130
|
+
const r = await build({
|
|
131
|
+
stdin: { contents: new TextDecoder().decode(code), loader: 'js', sourcefile: BUNDLE_NAME },
|
|
132
|
+
bundle: true,
|
|
133
|
+
write: false,
|
|
134
|
+
format: 'esm',
|
|
135
|
+
platform: 'neutral',
|
|
136
|
+
logLevel: 'silent',
|
|
137
|
+
logOverride: { [COMPUTED_IMPORT]: 'warning' },
|
|
138
|
+
plugins: [
|
|
139
|
+
{
|
|
140
|
+
name: 'gf-bundle-imports',
|
|
141
|
+
setup(b) {
|
|
142
|
+
b.onResolve({ filter: /.*/ }, a => {
|
|
143
|
+
if (a.kind === 'import-statement' || a.kind === 'dynamic-import') modules.add(a.path);
|
|
144
|
+
return { path: a.path, external: true };
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
for (const w of r.warnings)
|
|
151
|
+
if (w.id === COMPUTED_IMPORT) {
|
|
152
|
+
const u = unbundled(w);
|
|
153
|
+
if (u.computed) computed++;
|
|
154
|
+
else modules.add(u.module);
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return { modules, computed };
|
|
160
|
+
}
|
|
161
|
+
/** What xgames.fun refuses, said where it is: a socket module. */
|
|
162
|
+
const socketWords = (where, mod) =>
|
|
163
|
+
`${where} imports ${mod}, which opens raw network connections or loads modules by name: xgames.fun refuses a server that can (a game's server reaches the network with fetch() only). Take out what needs it (a database driver, a mail or chat client) and call a service over fetch() instead`;
|
|
164
|
+
/** … and an import() of a name made at run time. */
|
|
165
|
+
const computedWords = where =>
|
|
166
|
+
`${where} imports a module named at run time (import() of something other than a plain string): xgames.fun refuses it, since it could load any module. Import each module by its own name (import('./levels/forest.js')), or a path esbuild can list (import(\`./levels/\${name}.js\`) bundles every file there)`;
|
|
91
167
|
|
|
92
168
|
const sha256 = b => createHash('sha256').update(b).digest('hex');
|
|
93
169
|
const lineAt = (text, i) => text.slice(0, i).split('\n').length;
|
|
@@ -135,7 +211,6 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
|
|
|
135
211
|
const root = path.resolve(dir);
|
|
136
212
|
const cfg = await readServerConfig(root);
|
|
137
213
|
if (!cfg.has) return { has: false, problems: cfg.problems };
|
|
138
|
-
const nodeCompat = cfg.compatibilityFlags.includes('nodejs_compat');
|
|
139
214
|
const out = {
|
|
140
215
|
has: true,
|
|
141
216
|
ok: false,
|
|
@@ -158,6 +233,27 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
|
|
|
158
233
|
const { build } = await loadEsbuild();
|
|
159
234
|
if (cfg.stock) return bundleStock(build, out, { minify });
|
|
160
235
|
const refusedImports = [];
|
|
236
|
+
const rel = p => path.relative(root, path.resolve(root, p)).replace(/\\/g, '/');
|
|
237
|
+
// what the build saw import a socket module or a Node built-in, by module: [the words for each file that has it].
|
|
238
|
+
// Only a module the bundle still imports refuses it (below): esbuild leaves out what nothing uses.
|
|
239
|
+
const seen = new Map();
|
|
240
|
+
const saw = (mod, line) => {
|
|
241
|
+
const lines = seen.get(mod) ?? [];
|
|
242
|
+
if (!lines.includes(line)) lines.push(line);
|
|
243
|
+
seen.set(mod, lines);
|
|
244
|
+
};
|
|
245
|
+
const sockets = {
|
|
246
|
+
name: 'gf-server-sockets',
|
|
247
|
+
setup(b) {
|
|
248
|
+
b.onResolve({ filter: /.*/ }, args => {
|
|
249
|
+
if (args.kind !== 'import-statement' && args.kind !== 'dynamic-import') return undefined;
|
|
250
|
+
const mod = socketModule(args.path);
|
|
251
|
+
if (!mod) return undefined;
|
|
252
|
+
saw(mod, socketWords(rel(args.importer), mod));
|
|
253
|
+
return { path: args.path, external: true };
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
};
|
|
161
257
|
// compiled wasm: its own module, never inlined (the runtime can't compile wasm from bytes)
|
|
162
258
|
const wasmFiles = new Map(); // name → absolute path
|
|
163
259
|
const wasm = {
|
|
@@ -182,36 +278,31 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
|
|
|
182
278
|
});
|
|
183
279
|
},
|
|
184
280
|
};
|
|
185
|
-
// Node's built-ins:
|
|
281
|
+
// Node's built-ins: a game's server has none (xgames.fun offers no nodejs_compat), named in words, never a bare name
|
|
282
|
+
// esbuild cannot find
|
|
186
283
|
const guard = {
|
|
187
284
|
name: 'gf-server-node',
|
|
188
285
|
setup(b) {
|
|
189
286
|
b.onResolve({ filter: /^node:|^[a-z_]+(\/[a-z_]+)?$/ }, args => {
|
|
190
287
|
const bare = args.path.replace(/^node:/, '');
|
|
191
288
|
if (!args.path.startsWith('node:') && !NODE_BUILTINS.has(bare)) return undefined; // a package of the game's own
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
else if (!nodeCompat)
|
|
198
|
-
refusedImports.push(
|
|
199
|
-
`${where} imports ${args.path}: Node's built-ins need nodejs_compat (gamesfun.json server.compatibilityFlags: ["nodejs_compat"]); or use the Web APIs (fetch, crypto.subtle, URL, TextEncoder)`,
|
|
200
|
-
);
|
|
201
|
-
else if (NODE_REFUSED.includes(bare))
|
|
202
|
-
refusedImports.push(
|
|
203
|
-
`${where} imports ${args.path}: the hosted runtime (a Worker) does not have it. Keep data in env.GF (saves, scores) or a Durable Object's storage, and use the Web APIs (fetch, crypto.subtle, URL)`,
|
|
204
|
-
);
|
|
205
|
-
return { path: args.path.startsWith('node:') ? args.path : `node:${bare}`, external: true };
|
|
289
|
+
saw(
|
|
290
|
+
`node:${bare}`,
|
|
291
|
+
`${rel(args.importer)} imports ${JSON.stringify(args.path)}, one of Node's built-in modules, which a game's server on xgames.fun does not have. Use the Web APIs a Worker has: fetch, crypto.subtle and crypto.getRandomValues, URL, TextEncoder and TextDecoder, Response; keep data in env.GF (saves, scores), a database or a Durable Object's storage`,
|
|
292
|
+
);
|
|
293
|
+
return { path: `node:${bare}`, external: true };
|
|
206
294
|
});
|
|
207
295
|
},
|
|
208
296
|
};
|
|
209
297
|
let result;
|
|
210
298
|
try {
|
|
211
299
|
result = await build({
|
|
212
|
-
...buildOptions(path.join(root, cfg.entry), { minify
|
|
300
|
+
...buildOptions(path.join(root, cfg.entry), { minify }),
|
|
213
301
|
absWorkingDir: root,
|
|
214
|
-
plugins: [guard, wasm],
|
|
302
|
+
plugins: [sockets, guard, wasm],
|
|
303
|
+
// an import() esbuild cannot bundle (its argument is not a plain string) stays in the bundle, where xgames.fun
|
|
304
|
+
// refuses it: said as a warning with its file and line, in a package's code too
|
|
305
|
+
logOverride: { [COMPUTED_IMPORT]: 'warning' },
|
|
215
306
|
});
|
|
216
307
|
} catch (e) {
|
|
217
308
|
for (const m of e.errors ?? [{ text: e.message }])
|
|
@@ -220,6 +311,26 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
|
|
|
220
311
|
);
|
|
221
312
|
return out;
|
|
222
313
|
}
|
|
314
|
+
// the bundle as it would be sent, read as xgames.fun reads it: only what it still imports is refused
|
|
315
|
+
const kept = await bundleImports(build, result.outputFiles[0].contents);
|
|
316
|
+
const keptMods = kept ? new Set([...kept.modules].map(m => socketModule(m) ?? m)) : null;
|
|
317
|
+
// a plain-string import() esbuild did not take (a second argument it does not read) is an import of that module
|
|
318
|
+
const computedAt = [];
|
|
319
|
+
for (const w of result.warnings ?? [])
|
|
320
|
+
if (w.id === COMPUTED_IMPORT) {
|
|
321
|
+
const where = w.location ? `${rel(w.location.file)}:${w.location.line}` : 'server/';
|
|
322
|
+
const u = unbundled(w);
|
|
323
|
+
if (u.computed) computedAt.push(where);
|
|
324
|
+
else if (socketModule(u.module)) saw(socketModule(u.module), socketWords(where, socketModule(u.module)));
|
|
325
|
+
}
|
|
326
|
+
for (const [mod, lines] of seen) if (!keptMods || keptMods.has(mod)) refusedImports.push(...lines);
|
|
327
|
+
if (kept) {
|
|
328
|
+
// a module the bundle imports that the build never named (not expected; said for the bundle)
|
|
329
|
+
for (const mod of keptMods)
|
|
330
|
+
if (socketModule(mod) && !seen.has(mod)) refusedImports.push(socketWords(`the server bundle`, mod));
|
|
331
|
+
if (kept.computed)
|
|
332
|
+
refusedImports.push(...(computedAt.length ? computedAt : ['the server bundle']).map(computedWords));
|
|
333
|
+
} else refusedImports.push(...computedAt.map(computedWords));
|
|
223
334
|
out.problems.push(...refusedImports);
|
|
224
335
|
const inputs = Object.keys(result.metafile.inputs).filter(p => !/^(cloudflare|node):/.test(p));
|
|
225
336
|
for (const p of inputs) {
|
|
@@ -320,7 +431,7 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
|
|
|
320
431
|
// here would see it. Read in an unminified pass, where each export's declaration is its own line.
|
|
321
432
|
const others = out.exports.filter(n => n !== 'default' && !cfg.objects.some(o => o.class === n));
|
|
322
433
|
if (others.length) {
|
|
323
|
-
const plain = await plainExports(build, path.join(root, cfg.entry), root
|
|
434
|
+
const plain = await plainExports(build, path.join(root, cfg.entry), root).catch(() => []);
|
|
324
435
|
for (const { name, value } of plain.filter(x => others.includes(x.name)))
|
|
325
436
|
out.problems.push(
|
|
326
437
|
`${cfg.entry} exports ${name} = ${value}: the hosted runtime takes only functions, classes and objects as a server's exports, and refuses to start a server with a plain value. Keep ${name} unexported (or in a module the server imports)`,
|