hammerkit 1.27.0-next.29 → 1.27.0-next.30

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.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ownPath = exports.EndpointGateway = exports.LOGIN_HOST = void 0;
3
+ exports.ownPath = exports.returnPath = exports.EndpointGateway = exports.LOGIN_HOST = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const promises_1 = require("fs/promises");
6
6
  const http_1 = require("http");
@@ -15,6 +15,18 @@ const units_1 = require("../utils/units");
15
15
  // interface, for testing) goes without the prefix.
16
16
  const SESSION_COOKIE = '__Host-hammerkit-session';
17
17
  const PLAIN_SESSION_COOKIE = 'hammerkit-session';
18
+ // The cookie of a sign-in under way, on the login host, one per sign-in (by its state): the
19
+ // browser keeps what the callback needs, so a caller who is not signed in fills no table of
20
+ // the server's.
21
+ const LOGIN_COOKIE = '__Host-hammerkit-login-';
22
+ const PLAIN_LOGIN_COOKIE = 'hammerkit-login-';
23
+ // The cookie of the endpoint host that a sign-in started from, which the one-time code that
24
+ // comes back has to match: a code is for the browser that asked for it, not one it is sent to.
25
+ const BIND_COOKIE = '__Host-hammerkit-bind-';
26
+ const PLAIN_BIND_COOKIE = 'hammerkit-bind-';
27
+ // the most of the page to go back to that a sign-in keeps: its cookie stays below what a
28
+ // browser takes (4096 bytes for name and value)
29
+ const MAX_RETURN_PATH = 1024;
18
30
  // the host of the domain that signs people in, the one redirect URI registered at the issuer
19
31
  exports.LOGIN_HOST = 'login';
20
32
  // the paths of an endpoint host that are the server's, never the app's
@@ -22,7 +34,9 @@ const OWN_PATH = '/.hammerkit/';
22
34
  // how long a sign-in may take at the issuer, and how long its one-time code holds
23
35
  const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
24
36
  const CODE_TIMEOUT_MS = 60 * 1000;
25
- const MAX_PENDING_LOGINS = 1000;
37
+ // the states of sign-ins that came back, so one is taken once; the oldest goes first when
38
+ // there are more, then only the issuer's one-time code holds
39
+ const MAX_SPENT_LOGINS = 10000;
26
40
  const DISCOVERY_TTL_MS = 10 * 60 * 1000;
27
41
  const FETCH_TIMEOUT_MS = 10000;
28
42
  /**
@@ -37,7 +51,8 @@ class EndpointGateway {
37
51
  this.config = config;
38
52
  this.registry = registry;
39
53
  this.options = options;
40
- this.pending = new Map();
54
+ // the state of each sign-in that came back, until it would have expired
55
+ this.spent = new Map();
41
56
  this.codes = new Map();
42
57
  this.base = { scheme: 'https', port: '' };
43
58
  this.fetcher = options.fetch ?? fetch;
@@ -105,11 +120,17 @@ class EndpointGateway {
105
120
  }
106
121
  const principal = await this.caller(label, req);
107
122
  if (!principal) {
108
- if (this.loginVerifier && req.method === 'GET' && acceptsHtml(req.headers)) {
123
+ if (this.loginVerifier && req.method === 'GET' && acceptsHtml(req.headers) && isPageLoad(req.headers)) {
124
+ const bind = (0, crypto_1.randomBytes)(18).toString('base64url');
109
125
  const start = new URL(this.url(exports.LOGIN_HOST, '/start'));
110
126
  start.searchParams.set('host', label);
111
127
  start.searchParams.set('path', url.pathname + url.search);
112
- res.writeHead(302, { location: start.toString(), 'cache-control': 'no-store' });
128
+ start.searchParams.set('bind', bind);
129
+ res.writeHead(302, {
130
+ location: start.toString(),
131
+ 'set-cookie': this.cookie(this.bindCookieName(bind), bind, LOGIN_TIMEOUT_MS),
132
+ 'cache-control': 'no-store',
133
+ });
113
134
  res.end();
114
135
  return;
115
136
  }
@@ -359,24 +380,31 @@ ${run.state === 'up'
359
380
  if (url.pathname === '/start') {
360
381
  const host = url.searchParams.get('host') ?? '';
361
382
  const path = url.searchParams.get('path') ?? '/';
383
+ const bind = url.searchParams.get('bind') ?? '';
362
384
  if (!/^[a-z0-9-]+$/.test(host) || host === exports.LOGIN_HOST || !this.registry.endpoint(host)) {
363
385
  return page(res, 404, 'Not found', '<p>There is no endpoint at this address.</p>');
364
386
  }
365
- const endpoints = await this.endpointsOf(login.issuer);
366
- this.sweep();
367
- if (this.pending.size >= MAX_PENDING_LOGINS) {
368
- return page(res, 503, 'Busy', '<p>Too many sign-ins at once, try again in a minute.</p>');
387
+ if (!BIND_PATTERN.test(bind)) {
388
+ return page(res, 400, 'Not signed in', '<p>Open the endpoint to sign in.</p>');
389
+ }
390
+ // a sign-in starts when a person goes to it, not from a request of a page, which could
391
+ // pile up cookies of sign-ins in their browser
392
+ if (req.headers['sec-fetch-mode'] !== undefined && req.headers['sec-fetch-mode'] !== 'navigate') {
393
+ return page(res, 403, 'Not allowed', '<p>Open the endpoint to sign in.</p>');
369
394
  }
395
+ const endpoints = await this.endpointsOf(login.issuer);
370
396
  const state = (0, crypto_1.randomBytes)(24).toString('base64url');
371
397
  const verifier = (0, crypto_1.randomBytes)(32).toString('base64url');
372
398
  const nonce = (0, crypto_1.randomBytes)(24).toString('base64url');
373
- this.pending.set(state, {
399
+ const pending = {
400
+ state,
401
+ bind,
374
402
  host,
375
- path: ownPath(path),
403
+ path: returnPath(path),
376
404
  verifier,
377
405
  nonce,
378
406
  expires: this.now() + LOGIN_TIMEOUT_MS,
379
- });
407
+ };
380
408
  const authorize = new URL(endpoints.authorize);
381
409
  authorize.searchParams.set('response_type', 'code');
382
410
  authorize.searchParams.set('client_id', login.clientId);
@@ -386,22 +414,28 @@ ${run.state === 'up'
386
414
  authorize.searchParams.set('nonce', nonce);
387
415
  authorize.searchParams.set('code_challenge', (0, crypto_1.createHash)('sha256').update(verifier).digest('base64url'));
388
416
  authorize.searchParams.set('code_challenge_method', 'S256');
389
- res.writeHead(302, { location: authorize.toString(), 'cache-control': 'no-store' });
417
+ res.writeHead(302, {
418
+ location: authorize.toString(),
419
+ 'set-cookie': this.loginCookie(pending),
420
+ 'cache-control': 'no-store',
421
+ });
390
422
  res.end();
391
423
  return;
392
424
  }
393
425
  if (url.pathname === '/callback') {
394
426
  const state = url.searchParams.get('state') ?? '';
395
- const pending = this.pending.get(state);
396
- this.pending.delete(state);
397
- if (!pending || pending.expires < this.now()) {
398
- return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
427
+ const pending = this.readLogin(state, req.headers.cookie);
428
+ if (pending) {
429
+ res.setHeader('set-cookie', this.clearLoginCookie(state));
399
430
  }
400
431
  const code = url.searchParams.get('code');
401
- if (!code) {
432
+ if (pending && !code) {
402
433
  const error = url.searchParams.get('error') ?? 'no code';
403
434
  return page(res, 400, 'Not signed in', `<p>The sign-in did not succeed: ${escape(error.slice(0, 200))}.</p>`);
404
435
  }
436
+ if (!pending || !code || !this.spend(pending)) {
437
+ return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
438
+ }
405
439
  let principal;
406
440
  try {
407
441
  principal = await this.exchange(code, pending);
@@ -417,6 +451,7 @@ ${run.state === 'up'
417
451
  }
418
452
  const issued = (0, crypto_1.randomBytes)(24).toString('base64url');
419
453
  this.codes.set(issued, {
454
+ bind: pending.bind,
420
455
  host: pending.host,
421
456
  path: pending.path,
422
457
  principal,
@@ -432,6 +467,61 @@ ${run.state === 'up'
432
467
  return page(res, 404, 'Not found', '<p>There is nothing here.</p>');
433
468
  }
434
469
  // The code of the issuer for its tokens, and the ID token for who signed in.
470
+ // A sign-in comes back once: its state is kept as spent until it would have expired. Past
471
+ // MAX_SPENT_LOGINS the oldest are forgotten, which anybody can bring about with sign-ins of
472
+ // their own; a state forgotten that way still needs its cookie and a code the issuer takes
473
+ // a second time, which it doesn't (codes of the authorization code flow are one-time).
474
+ spend(pending) {
475
+ const now = this.now();
476
+ if (pending.expires < now || this.spent.has(pending.state)) {
477
+ return false;
478
+ }
479
+ this.sweep();
480
+ while (this.spent.size >= MAX_SPENT_LOGINS) {
481
+ const oldest = this.spent.keys().next().value;
482
+ this.spent.delete(oldest);
483
+ }
484
+ this.spent.set(pending.state, pending.expires);
485
+ return true;
486
+ }
487
+ loginCookieName(state) {
488
+ // the state is base64url, which a cookie name may carry
489
+ return `${this.base.scheme === 'https' ? LOGIN_COOKIE : PLAIN_LOGIN_COOKIE}${state.slice(0, 16)}`;
490
+ }
491
+ bindCookieName(bind) {
492
+ return `${this.base.scheme === 'https' ? BIND_COOKIE : PLAIN_BIND_COOKIE}${bind.slice(0, 16)}`;
493
+ }
494
+ // A cookie of the host the request is for (no Domain), for so long; 0 clears it.
495
+ cookie(name, value, ms) {
496
+ return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.max(0, Math.floor(ms / 1000))}${this.base.scheme === 'https' ? '; Secure' : ''}`;
497
+ }
498
+ loginCookie(pending) {
499
+ const payload = Buffer.from(JSON.stringify(pending)).toString('base64url');
500
+ return this.cookie(this.loginCookieName(pending.state), `${payload}.${this.sign(`login\0${payload}`)}`, LOGIN_TIMEOUT_MS);
501
+ }
502
+ clearLoginCookie(state) {
503
+ return this.cookie(this.loginCookieName(state), '', 0);
504
+ }
505
+ // The sign-in of this state, from its cookie, when the server signed it.
506
+ readLogin(state, header) {
507
+ if (!/^[A-Za-z0-9_-]{32}$/.test(state)) {
508
+ return null;
509
+ }
510
+ const value = readCookie(header, this.loginCookieName(state));
511
+ const [payload, signature] = (value ?? '').split('.');
512
+ if (!payload || !signature || !safeEqual(signature, this.sign(`login\0${payload}`))) {
513
+ return null;
514
+ }
515
+ try {
516
+ const pending = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
517
+ return pending.state === state && typeof pending.verifier === 'string' && typeof pending.bind === 'string'
518
+ ? pending
519
+ : null;
520
+ }
521
+ catch {
522
+ return null;
523
+ }
524
+ }
435
525
  async exchange(code, pending) {
436
526
  const login = this.config.endpoints?.login;
437
527
  if (!login || !this.loginVerifier) {
@@ -484,23 +574,35 @@ ${run.state === 'up'
484
574
  const code = url.searchParams.get('code') ?? '';
485
575
  const issued = this.codes.get(code);
486
576
  this.codes.delete(code);
487
- if (!issued || issued.host !== label || issued.expires < this.now()) {
577
+ const bind = issued ? readCookie(req.headers.cookie, this.bindCookieName(issued.bind)) : undefined;
578
+ if (issued) {
579
+ res.setHeader('set-cookie', this.cookie(this.bindCookieName(issued.bind), '', 0));
580
+ }
581
+ if (!issued ||
582
+ issued.host !== label ||
583
+ issued.expires < this.now() ||
584
+ // the browser that started the sign-in at this host, no other one the link was sent to
585
+ !bind ||
586
+ !safeEqual(bind, issued.bind)) {
488
587
  return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
489
588
  }
490
589
  const found = this.registry.endpoint(label);
491
590
  const ends = Date.parse(found?.run.expiresAt ?? '');
492
- const session = this.now() + (0, units_1.parseDuration)(this.config.endpoints?.session ?? '8h');
591
+ const session = this.now() + (0, units_1.parseDuration)(this.config.endpoints?.session ?? '1h');
493
592
  const exp = Number.isFinite(ends) ? Math.min(session, Math.max(ends, this.now() + LOGIN_TIMEOUT_MS)) : session;
494
593
  res.writeHead(303, {
495
594
  location: issued.path,
496
- 'set-cookie': this.sessionCookie({
497
- host: label,
498
- iss: issued.principal.iss,
499
- sub: issued.principal.sub,
500
- groups: issued.principal.groups,
501
- claims: this.matchedClaims(issued.principal),
502
- exp,
503
- }),
595
+ 'set-cookie': [
596
+ this.cookie(this.bindCookieName(issued.bind), '', 0),
597
+ this.sessionCookie({
598
+ host: label,
599
+ iss: issued.principal.iss,
600
+ sub: issued.principal.sub,
601
+ groups: issued.principal.groups,
602
+ claims: this.matchedClaims(issued.principal),
603
+ exp,
604
+ }),
605
+ ],
504
606
  'cache-control': 'no-store',
505
607
  });
506
608
  res.end();
@@ -523,11 +625,7 @@ ${run.state === 'up'
523
625
  return `${this.cookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${this.base.scheme === 'https' ? '; Secure' : ''}`;
524
626
  }
525
627
  readSession(label, header) {
526
- const name = this.cookieName();
527
- const value = (header ?? '')
528
- .split(/;\s*/)
529
- .find((cookie) => cookie.startsWith(`${name}=`))
530
- ?.slice(name.length + 1);
628
+ const value = readCookie(header, this.cookieName());
531
629
  const [payload, signature] = (value ?? '').split('.');
532
630
  if (!payload || !signature || !safeEqual(signature, this.sign(payload))) {
533
631
  return null;
@@ -555,9 +653,9 @@ ${run.state === 'up'
555
653
  }
556
654
  sweep() {
557
655
  const now = this.now();
558
- for (const [state, login] of this.pending) {
559
- if (login.expires < now) {
560
- this.pending.delete(state);
656
+ for (const [state, expires] of this.spent) {
657
+ if (expires < now) {
658
+ this.spent.delete(state);
561
659
  }
562
660
  }
563
661
  for (const [code, issued] of this.codes) {
@@ -568,11 +666,37 @@ ${run.state === 'up'
568
666
  }
569
667
  }
570
668
  exports.EndpointGateway = EndpointGateway;
669
+ const BIND_PATTERN = /^[A-Za-z0-9_-]{24}$/;
670
+ // The page a sign-in goes back to: a path of its own host, and only the path when the query
671
+ // is too long for the cookie of the sign-in.
672
+ function returnPath(path) {
673
+ const own = ownPath(path);
674
+ if (own.length <= MAX_RETURN_PATH) {
675
+ return own;
676
+ }
677
+ const pathname = own.split('?')[0];
678
+ return pathname.length <= MAX_RETURN_PATH ? pathname : '/';
679
+ }
680
+ exports.returnPath = returnPath;
681
+ function readCookie(header, name) {
682
+ return (header ?? '')
683
+ .split(/;\s*/)
684
+ .find((cookie) => cookie.startsWith(`${name}=`))
685
+ ?.slice(name.length + 1);
686
+ }
571
687
  function safeEqual(a, b) {
572
688
  const left = Buffer.from(a);
573
689
  const right = Buffer.from(b);
574
690
  return left.length === right.length && (0, crypto_1.timingSafeEqual)(left, right);
575
691
  }
692
+ // Whether the browser loads a page of its own, which a sign-in can go through: not a request
693
+ // of a page (fetch, htmx, a frame), which would only pile up cookies of sign-ins. Without
694
+ // Sec-Fetch-*, an older browser or another client, by its Accept alone.
695
+ function isPageLoad(headers) {
696
+ const mode = headers['sec-fetch-mode'];
697
+ const dest = headers['sec-fetch-dest'];
698
+ return (mode === undefined || mode === 'navigate') && (dest === undefined || dest === 'document');
699
+ }
576
700
  function acceptsHtml(headers) {
577
701
  return (headers.accept ?? '').includes('text/html');
578
702
  }
@@ -615,8 +615,9 @@ exports.serverConfigSchema = zod_1.z
615
615
  })
616
616
  .strict()
617
617
  .optional(),
618
- // how long a sign-in at one endpoint lasts, at most until its run ends
619
- session: duration.default('8h'),
618
+ // how long a sign-in at one endpoint lasts, at most until its run ends: the groups and
619
+ // claims of the sign-in hold that long, a change at the issuer counts at the next one
620
+ session: duration.default('1h'),
620
621
  })
621
622
  .strict()
622
623
  .optional(),
@@ -930,6 +931,15 @@ exports.serverConfigSchema = zod_1.z
930
931
  });
931
932
  }
932
933
  }
934
+ // only the server is to reach the services behind endpoints, which the NetworkPolicy of
935
+ // a service run says: without it, any pod of the cluster would get past the sign-in
936
+ if (backend.type === 'kubernetes' && backend.networkPolicy !== 'enforced') {
937
+ ctx.addIssue({
938
+ code: zod_1.z.ZodIssueCode.custom,
939
+ path: ['backend', 'networkPolicy'],
940
+ message: 'endpoints keep the services of a run to the server with a NetworkPolicy, set networkPolicy: enforced once the network plugin of the cluster enforces them',
941
+ });
942
+ }
933
943
  if (backend.type === 'kubernetes' && backend.kubeconfig) {
934
944
  ctx.addIssue({
935
945
  code: zod_1.z.ZodIssueCode.custom,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.27.0-next.29",
4
+ "version": "1.27.0-next.30",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",