@pellux/goodvibes-sdk 0.21.28 → 0.21.29

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.
@@ -10,6 +10,14 @@ interface HttpListenerConfig {
10
10
  serveFactory?: typeof Bun.serve;
11
11
  /** Max requests per 60-second window per IP. Default: 60. */
12
12
  rateLimit?: number;
13
+ /** Max POST /login attempts per 60-second window per IP. Default: 5. */
14
+ loginRateLimit?: number;
15
+ /**
16
+ * When true, x-forwarded-for / x-real-ip headers are trusted for client IP
17
+ * extraction (rate limiting, audit logging). Only enable behind a trusted
18
+ * reverse proxy. Overrides the httpListener.trustProxy config value when set.
19
+ */
20
+ trustProxy?: boolean;
13
21
  /** Pre-configured UserAuthManager owned by the runtime service graph. */
14
22
  userAuth: UserAuthManager;
15
23
  }
@@ -36,6 +44,10 @@ export declare class HttpListener {
36
44
  private authToken;
37
45
  private userAuth;
38
46
  private rateLimiter;
47
+ /** Dedicated tight rate-limiter for POST /login (SEC-03). */
48
+ private loginRateLimiter;
49
+ /** Whether to trust x-forwarded-for / x-real-ip for client IP resolution. */
50
+ private trustProxy;
39
51
  private readonly configManager;
40
52
  private readonly serveFactory;
41
53
  private tlsState;
@@ -1 +1 @@
1
- {"version":3,"file":"http-listener.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/daemon/http-listener.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAMxD,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAWrD,UAAU,kBAAkB;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,aAAa,EAAE,aAAa,CAAC;IAC7B,YAAY,CAAC,EAAE,OAAO,GAAG,CAAC,KAAK,CAAC;IAChC,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yEAAyE;IACzE,QAAQ,EAAE,eAAe,CAAC;CAC3B;AAED,UAAU,gBAAgB;IACxB,YAAY,EAAE,OAAO,CAAC;CACvB;AA0ED;;;;;;;;GAQG;AACH,qBAAa,YAAY;IAsBX,OAAO,CAAC,MAAM;IArB1B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,MAAM,CAA6C;IAC3D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,cAAc,CAAW;IACjC,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,QAAQ,CAAkB;IAClC,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAmB;IAChD,OAAO,CAAC,QAAQ,CAA0C;IAC1D,4EAA4E;IAC5E,OAAO,CAAC,iBAAiB,CAA6B;IACtD,gFAAgF;IAChF,OAAO,CAAC,WAAW,CAAS;IAC5B,sEAAsE;IACtE,OAAO,CAAC,kBAAkB,CAA8B;IACxD,0FAA0F;IAC1F,OAAO,CAAC,aAAa,CAAS;gBAEV,MAAM,EAAE,kBAAkB;IAiB9C;;;;OAIG;IACH,MAAM,CAAC,YAAY,EAAE,gBAAgB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO;IAU/D;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsC5B;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAKrC;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB3B;;;OAGG;IACH,OAAO,CAAC,gCAAgC;IAwDxC;;OAEG;IACH,IAAI,SAAS,IAAI,OAAO,CAEvB;IAMD,OAAO,CAAC,SAAS;YAOH,aAAa;YAYb,aAAa;YAsCb,WAAW;YA6BX,aAAa;CAgD5B"}
1
+ {"version":3,"file":"http-listener.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/daemon/http-listener.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAMxD,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAWrD,UAAU,kBAAkB;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,aAAa,EAAE,aAAa,CAAC;IAC7B,YAAY,CAAC,EAAE,OAAO,GAAG,CAAC,KAAK,CAAC;IAChC,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yEAAyE;IACzE,QAAQ,EAAE,eAAe,CAAC;CAC3B;AAED,UAAU,gBAAgB;IACxB,YAAY,EAAE,OAAO,CAAC;CACvB;AA0ED;;;;;;;;GAQG;AACH,qBAAa,YAAY;IA0BX,OAAO,CAAC,MAAM;IAzB1B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,MAAM,CAA6C;IAC3D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,cAAc,CAAW;IACjC,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,QAAQ,CAAkB;IAClC,OAAO,CAAC,WAAW,CAAc;IACjC,6DAA6D;IAC7D,OAAO,CAAC,gBAAgB,CAAc;IACtC,6EAA6E;IAC7E,OAAO,CAAC,UAAU,CAAU;IAC5B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAmB;IAChD,OAAO,CAAC,QAAQ,CAA0C;IAC1D,4EAA4E;IAC5E,OAAO,CAAC,iBAAiB,CAA6B;IACtD,gFAAgF;IAChF,OAAO,CAAC,WAAW,CAAS;IAC5B,sEAAsE;IACtE,OAAO,CAAC,kBAAkB,CAA8B;IACxD,0FAA0F;IAC1F,OAAO,CAAC,aAAa,CAAS;gBAEV,MAAM,EAAE,kBAAkB;IAiC9C;;;;OAIG;IACH,MAAM,CAAC,YAAY,EAAE,gBAAgB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO;IAU/D;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsC5B;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAKrC;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB3B;;;OAGG;IACH,OAAO,CAAC,gCAAgC;IAwDxC;;OAEG;IACH,IAAI,SAAS,IAAI,OAAO,CAEvB;IAMD,OAAO,CAAC,SAAS;YAOH,aAAa;YAYb,aAAa;YA0Db,WAAW;YA6BX,aAAa;CAgD5B"}
@@ -93,6 +93,10 @@ export class HttpListener {
93
93
  authToken = null;
94
94
  userAuth;
95
95
  rateLimiter;
96
+ /** Dedicated tight rate-limiter for POST /login (SEC-03). */
97
+ loginRateLimiter;
98
+ /** Whether to trust x-forwarded-for / x-real-ip for client IP resolution. */
99
+ trustProxy;
96
100
  configManager;
97
101
  serveFactory;
98
102
  tlsState = null;
@@ -111,9 +115,22 @@ export class HttpListener {
111
115
  this.port = config.port ?? resolvedHttpBinding.port;
112
116
  this.host = config.host ?? resolvedHttpBinding.host;
113
117
  this.allowedOrigins = config.allowedOrigins ?? [];
118
+ // SEC-07: Refuse to construct when hostMode=network and allowedOrigins is not configured.
119
+ // An empty allowedOrigins combined with a network-reachable bind is an open CSRF vector —
120
+ // any browser-initiated cross-origin request will carry an Origin header and be accepted.
121
+ const effectiveHostMode = this.configManager.get('httpListener.hostMode') ?? 'local';
122
+ if (effectiveHostMode === 'network' && this.allowedOrigins.length === 0) {
123
+ throw new Error('SECURITY_UNSAFE_ORIGIN_CONFIG: hostMode=network requires non-empty allowedOrigins to prevent CSRF. '
124
+ + 'Set config.httpListener.allowedOrigins to a list of trusted origins '
125
+ + "(e.g. ['https://companion.example.com']).");
126
+ }
114
127
  this.hookDispatcher = config.hookDispatcher ?? null;
115
128
  this.userAuth = config.userAuth;
116
129
  this.rateLimiter = new RateLimiter(config.rateLimit ?? 60);
130
+ // SEC-03: /login gets its own tight budget (5 attempts/min per IP) to prevent
131
+ // scrypt-cost-throttled online brute-force attacks.
132
+ this.loginRateLimiter = new RateLimiter(config.loginRateLimit ?? 5);
133
+ this.trustProxy = config.trustProxy ?? Boolean(this.configManager.get('httpListener.trustProxy'));
117
134
  this.serveFactory = config.serveFactory ?? Bun.serve;
118
135
  }
119
136
  /**
@@ -190,8 +207,9 @@ export class HttpListener {
190
207
  this._configWatchUnsub?.();
191
208
  this._configWatchUnsub = null;
192
209
  }
193
- // Stop rate limiter sweep interval before tearing down (C5 fix)
210
+ // Stop rate limiter sweep intervals before tearing down.
194
211
  this.rateLimiter.stop();
212
+ this.loginRateLimiter.stop();
195
213
  this.server.stop(true);
196
214
  this.server = null;
197
215
  this.tlsState = null;
@@ -281,20 +299,35 @@ export class HttpListener {
281
299
  // Request handling
282
300
  // -------------------------------------------------------------------------
283
301
  async handleRequest(req) {
284
- // Handle login route before auth check
285
302
  const url = new URL(req.url);
303
+ const clientIp = extractForwardedClientIp(req, this.trustProxy || (this.tlsState?.trustProxy ?? false)) ?? 'unknown';
304
+ // SEC-07: CORS origin check applies to ALL paths (including /login).
305
+ // Logic:
306
+ // - No Origin header → same-origin or non-browser request → allow.
307
+ // - Origin present + allowedOrigins empty → no allowlist configured; block to
308
+ // prevent CSRF even when hostMode is not 'network' (e.g. 'auto' with a
309
+ // network-reachable bind). Constructor already refuses hostMode=network with
310
+ // empty allowedOrigins at startup, but defence-in-depth covers the request path.
311
+ // - Origin present + allowedOrigins non-empty → check allowlist.
312
+ const origin = req.headers.get('origin');
313
+ if (origin !== null) {
314
+ if (this.allowedOrigins.length === 0) {
315
+ return Response.json({ error: 'CORS_NOT_CONFIGURED: no allowedOrigins set' }, { status: 403 });
316
+ }
317
+ if (!this.allowedOrigins.includes(origin)) {
318
+ return Response.json({ error: 'ORIGIN_NOT_ALLOWED' }, { status: 403 });
319
+ }
320
+ }
321
+ // SEC-03: /login route handled AFTER origin check and under its own tight
322
+ // rate-limit budget (5/min per IP) to prevent online brute-force attacks.
323
+ // x-forwarded-for is only trustworthy when running behind a trusted reverse proxy.
286
324
  if (url.pathname === '/login' && req.method === 'POST') {
325
+ if (!this.loginRateLimiter.check(clientIp)) {
326
+ return Response.json({ error: 'Too many requests' }, { status: 429 });
327
+ }
287
328
  return this.handleLogin(req);
288
329
  }
289
- // CORS origin check when allowedOrigins is configured
290
- const origin = req.headers.get('origin') ?? '';
291
- if (this.allowedOrigins.length > 0 && origin && !this.allowedOrigins.includes(origin)) {
292
- return Response.json({ error: 'Origin not allowed' }, { status: 403 });
293
- }
294
- // Rate limiting (keyed by a synthetic IP-like string from headers)
295
- // Note: x-forwarded-for is only trustworthy when running behind a trusted reverse proxy.
296
- // If exposed directly to the internet, clients can spoof this header.
297
- const clientIp = extractForwardedClientIp(req, this.tlsState?.trustProxy ?? Boolean(this.configManager.get('httpListener.trustProxy'))) ?? 'unknown';
330
+ // General rate limiting for all other routes.
298
331
  if (!this.rateLimiter.check(clientIp)) {
299
332
  return Response.json({ error: 'Too many requests' }, { status: 429 });
300
333
  }
@@ -1 +1 @@
1
- {"version":3,"file":"user-auth.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/security/user-auth.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,cAAc;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,uBAAuB,EAAE,MAAM,CAAC;CACjC;AAUD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,uBAAuB,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,0BAA0B,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,SAAS,cAAc,EAAE,CAAC;IAC1C,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACjD;AAwJD,qBAAa,eAAe;IAC1B,OAAO,CAAC,KAAK,CAA+B;IAC5C,OAAO,CAAC,QAAQ,CAAkC;IAClD,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAS;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAU;gBAE3B,MAAM,EAAE,cAAc;IAqBlC,MAAM,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAI7C,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI;IAMjE,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI;IAShD,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW;IAY5C,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI;IAUlD,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IAIrC,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAW/C,SAAS,IAAI,cAAc,EAAE;IAS7B,YAAY,IAAI,iBAAiB,EAAE;IAOnC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,GAAE,SAAS,MAAM,EAAc,GAAG,cAAc;IAejG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAarC,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAgB5D,OAAO,IAAI,iBAAiB;IAc5B,4BAA4B,IAAI,OAAO;IAMvC,0BAA0B,IAAI,MAAM;IAIpC,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,OAAO;CAIhB"}
1
+ {"version":3,"file":"user-auth.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/security/user-auth.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,cAAc;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,uBAAuB,EAAE,MAAM,CAAC;CACjC;AAUD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,uBAAuB,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,0BAA0B,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,SAAS,cAAc,EAAE,CAAC;IAC1C,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACjD;AAgKD,qBAAa,eAAe;IAC1B,OAAO,CAAC,KAAK,CAA+B;IAC5C,OAAO,CAAC,QAAQ,CAAkC;IAClD,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAS;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAU;gBAE3B,MAAM,EAAE,cAAc;IAqBlC,MAAM,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAI7C,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI;IAMjE,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI;IAShD,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW;IAY5C,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI;IAUlD,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IAIrC,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAW/C,SAAS,IAAI,cAAc,EAAE;IAS7B,YAAY,IAAI,iBAAiB,EAAE;IAOnC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,GAAE,SAAS,MAAM,EAAc,GAAG,cAAc;IAejG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAarC,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAgB5D,OAAO,IAAI,iBAAiB;IAc5B,4BAA4B,IAAI,OAAO;IAMvC,0BAA0B,IAAI,MAAM;IAIpC,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,OAAO;CAIhB"}
@@ -1,5 +1,5 @@
1
1
  import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { logger } from '../utils/logger.js';
5
5
  const DEFAULT_SESSION_TTL_MS = 3_600_000;
@@ -48,26 +48,39 @@ function readBootstrapUsers(filePath) {
48
48
  return null;
49
49
  }
50
50
  }
51
- function writeBootstrapUsers(filePath, users) {
51
+ /**
52
+ * Write secret file content atomically at 0600 (owner read/write only).
53
+ * Uses write-to-tmp-then-rename for atomicity; chmod applied at both
54
+ * sides to defeat filesystem-reset behaviour on rename (observed on
55
+ * some Linux fs drivers).
56
+ */
57
+ function atomicWriteSecretFile(filePath, content) {
52
58
  mkdirSync(dirname(filePath), { recursive: true });
59
+ const tmpPath = filePath + '.tmp';
60
+ writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
61
+ try {
62
+ chmodSync(tmpPath, 0o600);
63
+ }
64
+ catch { /* best-effort */ }
65
+ renameSync(tmpPath, filePath);
66
+ try {
67
+ chmodSync(filePath, 0o600);
68
+ }
69
+ catch { /* best-effort */ }
70
+ }
71
+ function writeBootstrapUsers(filePath, users) {
72
+ // SEC-01: auth-user store contains scrypt password hashes — must be 0600.
53
73
  const payload = { version: 1, users };
54
- writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
74
+ atomicWriteSecretFile(filePath, `${JSON.stringify(payload, null, 2)}\n`);
55
75
  }
56
76
  function writeBootstrapCredentialFile(filePath, username, password) {
57
- mkdirSync(dirname(filePath), { recursive: true });
58
- writeFileSync(filePath, [
77
+ atomicWriteSecretFile(filePath, [
59
78
  'GoodVibes bootstrap auth',
60
79
  `username=${username}`,
61
80
  `password=${password}`,
62
81
  'purpose=Use these credentials only for local daemon/http listener /login routes when those surfaces are enabled.',
63
82
  'note=Normal SDK host usage does not require these credentials.',
64
- ].join('\n') + '\n', 'utf-8');
65
- try {
66
- chmodSync(filePath, 0o600);
67
- }
68
- catch {
69
- // best-effort only
70
- }
83
+ ].join('\n') + '\n');
71
84
  }
72
85
  function loadOrBootstrapUsers(filePath, credentialPath) {
73
86
  const existing = readBootstrapUsers(filePath);
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- let version = '0.21.28';
3
+ let version = '0.21.29';
4
4
  try {
5
5
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf-8'));
6
6
  version = pkg.version ?? version;
@@ -1 +1 @@
1
- {"version":3,"file":"daemon-home.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/workspace/daemon-home.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAOH,OAAO,KAAK,EAAE,eAAe,EAAwB,MAAM,4BAA4B,CAAC;AAOxF,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,oDAAoD;IACpD,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C,wCAAwC;IACxC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC;AAED;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,UAAU,CAAC,EAAE,eAAe,CAAC;CACvC;AAMD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,iBAAsB,GAAG,MAAM,CAiB5E;AAMD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAEtE;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,iBAAsB,EAC/B,IAAI,GAAE,uBAA4B,GACjC,cAAc,CA+BhB;AAMD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CASnF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAQ/E;AAMD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAWxF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAc1F"}
1
+ {"version":3,"file":"daemon-home.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/workspace/daemon-home.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAOH,OAAO,KAAK,EAAE,eAAe,EAAwB,MAAM,4BAA4B,CAAC;AAOxF,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,oDAAoD;IACpD,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C,wCAAwC;IACxC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC;AAED;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,UAAU,CAAC,EAAE,eAAe,CAAC;CACvC;AAMD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,iBAAsB,GAAG,MAAM,CAiB5E;AAMD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAEtE;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,iBAAsB,EAC/B,IAAI,GAAE,uBAA4B,GACjC,cAAc,CAgChB;AAMD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CASnF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAQ/E;AAMD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAWxF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAiB1F"}
@@ -82,14 +82,15 @@ export function runDaemonHomeMigration(daemonHomeDir, options = {}, deps = {}) {
82
82
  mkdirSync(daemonHomeDir, { recursive: true });
83
83
  const userGoodVibesRoot = join(homedir(), '.goodvibes');
84
84
  // Migrate auth-users.json from tui surface path
85
+ // SEC-02: credential-bearing files must land at 0600 regardless of source perms.
85
86
  const legacyAuthUsers = join(userGoodVibesRoot, 'tui', 'auth-users.json');
86
87
  if (existsSync(legacyAuthUsers)) {
87
- safeCopy(legacyAuthUsers, join(daemonHomeDir, 'auth-users.json'));
88
+ safeCopyIdentity(legacyAuthUsers, join(daemonHomeDir, 'auth-users.json'));
88
89
  }
89
90
  // Migrate auth-bootstrap.txt from tui surface path
90
91
  const legacyBootstrap = join(userGoodVibesRoot, 'tui', 'auth-bootstrap.txt');
91
92
  if (existsSync(legacyBootstrap)) {
92
- safeCopy(legacyBootstrap, join(daemonHomeDir, 'auth-bootstrap.txt'));
93
+ safeCopyIdentity(legacyBootstrap, join(daemonHomeDir, 'auth-bootstrap.txt'));
93
94
  }
94
95
  // NOTE: Operator tokens are NOT migrated from legacy workspace-scoped paths.
95
96
  // The canonical path is <daemonHomeDir>/operator-tokens.json (global, set at 0600).
@@ -175,8 +176,17 @@ export function writeDaemonSetting(daemonHomeDir, key, value) {
175
176
  // Overwrite corrupt file
176
177
  }
177
178
  }
178
- writeFileSync(tmpPath, JSON.stringify({ ...existing, [key]: value }, null, 2), 'utf-8');
179
+ // SEC-12: daemon-settings.json may contain sensitive pairing state; write at 0600.
180
+ writeFileSync(tmpPath, JSON.stringify({ ...existing, [key]: value }, null, 2), { encoding: 'utf-8', mode: 0o600 });
181
+ try {
182
+ chmodSync(tmpPath, 0o600);
183
+ }
184
+ catch { /* best-effort */ }
179
185
  renameSync(tmpPath, settingsPath);
186
+ try {
187
+ chmodSync(settingsPath, 0o600);
188
+ }
189
+ catch { /* best-effort */ }
180
190
  }
181
191
  // ---------------------------------------------------------------------------
182
192
  // Helpers
@@ -199,3 +209,25 @@ function safeCopy(src, dest) {
199
209
  return false;
200
210
  }
201
211
  }
212
+ /**
213
+ * Copy a credential-bearing identity file from src to dest and force mode 0600.
214
+ *
215
+ * SEC-02: `copyFileSync` preserves source permissions. Legacy TUI files may be
216
+ * 0644 (world-readable). Calling `chmodSync` after copy ensures the new
217
+ * canonical path is always owner-only regardless of the source's permissions.
218
+ * Never throws — failures are logged at warn level.
219
+ */
220
+ function safeCopyIdentity(src, dest) {
221
+ if (!safeCopy(src, dest))
222
+ return false;
223
+ try {
224
+ chmodSync(dest, 0o600);
225
+ }
226
+ catch (err) {
227
+ logger.warn('daemon-home: safeCopyIdentity chmod failed (best-effort)', {
228
+ dest,
229
+ error: err instanceof Error ? err.message : String(err),
230
+ });
231
+ }
232
+ return true;
233
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-sdk",
3
- "version": "0.21.28",
3
+ "version": "0.21.29",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mgd34msu/goodvibes-sdk.git"