nworks 1.2.1 → 1.2.2

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/dist/mcp.js CHANGED
@@ -31,13 +31,14 @@ import { join } from "path";
31
31
  function hasServiceAccountCreds(creds) {
32
32
  return !!(creds.serviceAccount && creds.privateKeyPath && creds.botId);
33
33
  }
34
+ var IS_UNIX = process.platform !== "win32";
34
35
  var CONFIG_DIR = join(homedir(), ".config", "nworks");
35
36
  var CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
36
37
  var TOKEN_PATH = join(CONFIG_DIR, "token.json");
37
38
  var USER_TOKEN_PATH = join(CONFIG_DIR, "user-token.json");
38
39
  async function ensureConfigDir() {
39
40
  if (!existsSync(CONFIG_DIR)) {
40
- await mkdir(CONFIG_DIR, { recursive: true });
41
+ await mkdir(CONFIG_DIR, { recursive: true, ...IS_UNIX && { mode: 448 } });
41
42
  }
42
43
  }
43
44
  function getCredentialsFromEnv() {
@@ -77,7 +78,11 @@ async function saveCredentials(creds, profile = "default") {
77
78
  profiles = JSON.parse(raw);
78
79
  }
79
80
  profiles[profile] = creds;
80
- await writeFile(CREDENTIALS_PATH, JSON.stringify(profiles, null, 2), "utf-8");
81
+ await writeFile(
82
+ CREDENTIALS_PATH,
83
+ JSON.stringify(profiles, null, 2),
84
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
85
+ );
81
86
  }
82
87
  async function loadToken(profile = "default") {
83
88
  if (!existsSync(TOKEN_PATH)) return null;
@@ -98,7 +103,11 @@ async function saveToken(token, profile = "default") {
98
103
  tokens = JSON.parse(raw);
99
104
  }
100
105
  tokens[profile] = token;
101
- await writeFile(TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
106
+ await writeFile(
107
+ TOKEN_PATH,
108
+ JSON.stringify(tokens, null, 2),
109
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
110
+ );
102
111
  }
103
112
  async function loadUserToken(profile = "default") {
104
113
  if (!existsSync(USER_TOKEN_PATH)) return null;
@@ -121,7 +130,11 @@ async function saveUserToken(token, profile = "default") {
121
130
  tokens = JSON.parse(raw);
122
131
  }
123
132
  tokens[profile] = token;
124
- await writeFile(USER_TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
133
+ await writeFile(
134
+ USER_TOKEN_PATH,
135
+ JSON.stringify(tokens, null, 2),
136
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
137
+ );
125
138
  }
126
139
  async function clearCredentials(profile = "default") {
127
140
  if (existsSync(CREDENTIALS_PATH)) {
@@ -131,25 +144,33 @@ async function clearCredentials(profile = "default") {
131
144
  await writeFile(
132
145
  CREDENTIALS_PATH,
133
146
  JSON.stringify(profiles, null, 2),
134
- "utf-8"
147
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
135
148
  );
136
149
  }
137
150
  if (existsSync(TOKEN_PATH)) {
138
151
  const raw = await readFile(TOKEN_PATH, "utf-8");
139
152
  const tokens = JSON.parse(raw);
140
153
  delete tokens[profile];
141
- await writeFile(TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
154
+ await writeFile(
155
+ TOKEN_PATH,
156
+ JSON.stringify(tokens, null, 2),
157
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
158
+ );
142
159
  }
143
160
  if (existsSync(USER_TOKEN_PATH)) {
144
161
  const raw = await readFile(USER_TOKEN_PATH, "utf-8");
145
162
  const tokens = JSON.parse(raw);
146
163
  delete tokens[profile];
147
- await writeFile(USER_TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
164
+ await writeFile(
165
+ USER_TOKEN_PATH,
166
+ JSON.stringify(tokens, null, 2),
167
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
168
+ );
148
169
  }
149
170
  }
150
171
 
151
172
  // src/auth/jwt.ts
152
- import { readFile as readFile2 } from "fs/promises";
173
+ import { readFile as readFile2, stat } from "fs/promises";
153
174
  import jwt from "jsonwebtoken";
154
175
  async function createJWT(creds) {
155
176
  if (!creds.serviceAccount || !creds.privateKeyPath) {
@@ -158,6 +179,15 @@ async function createJWT(creds) {
158
179
  );
159
180
  }
160
181
  const privateKey = await readFile2(creds.privateKeyPath, "utf-8");
182
+ if (process.platform !== "win32") {
183
+ const fileStat = await stat(creds.privateKeyPath);
184
+ const mode = fileStat.mode & 511;
185
+ if (mode & 63) {
186
+ console.error(
187
+ `[nworks] Warning: Private key file has permissions ${mode.toString(8)}. Recommended: 600`
188
+ );
189
+ }
190
+ }
161
191
  const now = Math.floor(Date.now() / 1e3);
162
192
  const payload = {
163
193
  iss: creds.clientId,
@@ -194,7 +224,8 @@ async function refreshToken(profile = "default") {
194
224
  });
195
225
  if (!res.ok) {
196
226
  const text = await res.text();
197
- throw new AuthError(`Token exchange failed (${res.status}): ${text}`);
227
+ const truncated = text.length > 200 ? text.substring(0, 200) + "..." : text;
228
+ throw new AuthError(`Token exchange failed (${res.status}): ${truncated}`);
198
229
  }
199
230
  const data = await res.json();
200
231
  const expiresIn = Number(data.expires_in);
@@ -210,7 +241,7 @@ async function refreshToken(profile = "default") {
210
241
  var BASE_URL = "https://www.worksapis.com/v1.0";
211
242
  var MAX_RETRIES = 3;
212
243
  function sleep(ms) {
213
- return new Promise((resolve) => setTimeout(resolve, ms));
244
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
214
245
  }
215
246
  async function request(opts, _retryCount = 0) {
216
247
  const { method, path, body, profile = "default" } = opts;
@@ -232,7 +263,9 @@ async function request(opts, _retryCount = 0) {
232
263
  return request(opts, _retryCount + 1);
233
264
  }
234
265
  if (res.status === 429 && _retryCount < MAX_RETRIES) {
235
- const retryAfter = parseInt(res.headers.get("Retry-After") ?? "5", 10);
266
+ const MAX_RETRY_AFTER = 60;
267
+ const rawRetry = parseInt(res.headers.get("Retry-After") ?? "5", 10);
268
+ const retryAfter = Math.min(Number.isNaN(rawRetry) ? 5 : rawRetry, MAX_RETRY_AFTER);
236
269
  await sleep(retryAfter * 1e3);
237
270
  return request(opts, _retryCount + 1);
238
271
  }
@@ -257,6 +290,58 @@ async function request(opts, _retryCount = 0) {
257
290
  return JSON.parse(text);
258
291
  }
259
292
 
293
+ // src/utils/sanitize.ts
294
+ import { basename, resolve, sep } from "path";
295
+ import { randomBytes } from "crypto";
296
+ function sanitizePathSegment(value) {
297
+ if (!value || typeof value !== "string") {
298
+ throw new Error("Path segment must be a non-empty string");
299
+ }
300
+ if (value === "me") return value;
301
+ if (/[/\\]/.test(value) || value.includes("..")) {
302
+ throw new Error(`Invalid path segment: "${value}"`);
303
+ }
304
+ return encodeURIComponent(value);
305
+ }
306
+ function sanitizeFileName(name) {
307
+ if (!name || typeof name !== "string") {
308
+ throw new Error("File name must be a non-empty string");
309
+ }
310
+ const base = basename(name);
311
+ return base.replace(/[\r\n"\\]/g, "_");
312
+ }
313
+ function validateLocalPath(filePath, allowedBase) {
314
+ const resolved = resolve(filePath);
315
+ if (allowedBase) {
316
+ const resolvedBase = resolve(allowedBase);
317
+ if (resolved !== resolvedBase && !resolved.startsWith(resolvedBase + sep)) {
318
+ throw new Error(`Path "${filePath}" escapes the allowed directory "${allowedBase}"`);
319
+ }
320
+ }
321
+ return resolved;
322
+ }
323
+ function validateRedirectUrl(location, allowedHosts) {
324
+ let parsed;
325
+ try {
326
+ parsed = new URL(location);
327
+ } catch {
328
+ throw new Error(`Invalid redirect URL: "${location}"`);
329
+ }
330
+ if (parsed.protocol !== "https:") {
331
+ throw new Error(`Redirect URL must use HTTPS: "${location}"`);
332
+ }
333
+ const isAllowed = allowedHosts.some(
334
+ (host) => parsed.hostname === host || parsed.hostname.endsWith("." + host)
335
+ );
336
+ if (!isAllowed) {
337
+ throw new Error(`Redirect to untrusted host: "${parsed.hostname}"`);
338
+ }
339
+ return location;
340
+ }
341
+ function generateSecureState() {
342
+ return randomBytes(32).toString("hex");
343
+ }
344
+
260
345
  // src/api/message.ts
261
346
  function buildContent(opts) {
262
347
  const type = opts.type ?? "text";
@@ -264,7 +349,11 @@ function buildContent(opts) {
264
349
  return { type: "text", text: opts.text };
265
350
  }
266
351
  if (type === "button") {
267
- const actions = opts.actions ? JSON.parse(opts.actions) : [];
352
+ const actions = opts.actions ? (() => {
353
+ const parsed = JSON.parse(opts.actions);
354
+ if (!Array.isArray(parsed)) throw new Error("actions must be a JSON array");
355
+ return parsed;
356
+ })() : [];
268
357
  return {
269
358
  type: "button_template",
270
359
  contentText: opts.text,
@@ -272,7 +361,11 @@ function buildContent(opts) {
272
361
  };
273
362
  }
274
363
  if (type === "list") {
275
- const elements = opts.elements ? JSON.parse(opts.elements) : [];
364
+ const elements = opts.elements ? (() => {
365
+ const parsed = JSON.parse(opts.elements);
366
+ if (!Array.isArray(parsed)) throw new Error("elements must be a JSON array");
367
+ return parsed;
368
+ })() : [];
276
369
  return {
277
370
  type: "list_template",
278
371
  coverData: { text: opts.text },
@@ -294,7 +387,7 @@ async function send(opts) {
294
387
  if (opts.to) {
295
388
  const result = await request({
296
389
  method: "POST",
297
- path: `/bots/${creds.botId}/users/${opts.to}/messages`,
390
+ path: `/bots/${sanitizePathSegment(creds.botId)}/users/${sanitizePathSegment(opts.to)}/messages`,
298
391
  body,
299
392
  profile
300
393
  });
@@ -303,7 +396,7 @@ async function send(opts) {
303
396
  if (opts.channel) {
304
397
  const result = await request({
305
398
  method: "POST",
306
- path: `/bots/${creds.botId}/channels/${opts.channel}/messages`,
399
+ path: `/bots/${sanitizePathSegment(creds.botId)}/channels/${sanitizePathSegment(opts.channel)}/messages`,
307
400
  body,
308
401
  profile
309
402
  });
@@ -320,7 +413,7 @@ async function listMembers(channelId, profile = "default") {
320
413
  }
321
414
  const result = await request({
322
415
  method: "GET",
323
- path: `/bots/${creds.botId}/channels/${channelId}/members`,
416
+ path: `/bots/${sanitizePathSegment(creds.botId)}/channels/${sanitizePathSegment(channelId)}/members`,
324
417
  profile
325
418
  });
326
419
  return { members: result.members ?? [], responseMetaData: result.responseMetaData };
@@ -341,7 +434,8 @@ import { randomUUID } from "crypto";
341
434
 
342
435
  // src/auth/oauth-user.ts
343
436
  import { createServer } from "http";
344
- import { URL } from "url";
437
+ import { randomBytes as randomBytes2 } from "crypto";
438
+ import { URL as URL2 } from "url";
345
439
  var AUTH_URL2 = "https://auth.worksmobile.com/oauth2/v2.0/authorize";
346
440
  var TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
347
441
  var REDIRECT_PORT = 9876;
@@ -356,14 +450,14 @@ function buildAuthorizeUrl(clientId, scope, state) {
356
450
  });
357
451
  return `${AUTH_URL2}?${params.toString()}`;
358
452
  }
359
- function waitForAuthCode() {
360
- return new Promise((resolve, reject) => {
453
+ function waitForAuthCode(expectedState) {
454
+ return new Promise((resolve2, reject) => {
361
455
  const timeout = setTimeout(() => {
362
456
  server.close();
363
457
  reject(new AuthError("OAuth login timed out (120s). Try again."));
364
458
  }, 12e4);
365
459
  const server = createServer((req, res) => {
366
- const url = new URL(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`);
460
+ const url = new URL2(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`);
367
461
  if (url.pathname !== "/callback") {
368
462
  res.writeHead(404);
369
463
  res.end("Not found");
@@ -371,6 +465,15 @@ function waitForAuthCode() {
371
465
  }
372
466
  const code = url.searchParams.get("code");
373
467
  const error = url.searchParams.get("error");
468
+ const state = url.searchParams.get("state");
469
+ if (state !== expectedState) {
470
+ res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" });
471
+ res.end("<h2>\uBCF4\uC548 \uC624\uB958</h2><p>state \uBD88\uC77C\uCE58. \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.</p>");
472
+ clearTimeout(timeout);
473
+ server.close();
474
+ reject(new AuthError("OAuth state mismatch \u2014 possible CSRF attack."));
475
+ return;
476
+ }
374
477
  if (error) {
375
478
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
376
479
  res.end("<h2>\uB85C\uADF8\uC778 \uC2E4\uD328</h2><p>\uC774 \uCC3D\uC744 \uB2EB\uC544\uB3C4 \uB429\uB2C8\uB2E4.</p>");
@@ -391,9 +494,9 @@ function waitForAuthCode() {
391
494
  res.end("<h2>\uB85C\uADF8\uC778 \uC131\uACF5!</h2><p>\uC774 \uCC3D\uC744 \uB2EB\uACE0 \uD130\uBBF8\uB110\uB85C \uB3CC\uC544\uAC00\uC138\uC694.</p>");
392
495
  clearTimeout(timeout);
393
496
  server.close();
394
- resolve(code);
497
+ resolve2(code);
395
498
  });
396
- server.listen(REDIRECT_PORT, () => {
499
+ server.listen(REDIRECT_PORT, "127.0.0.1", () => {
397
500
  });
398
501
  server.on("error", (err) => {
399
502
  clearTimeout(timeout);
@@ -416,7 +519,8 @@ async function exchangeCodeForToken(code, clientId, clientSecret) {
416
519
  });
417
520
  if (!res.ok) {
418
521
  const text = await res.text();
419
- throw new AuthError(`Token exchange failed (${res.status}): ${text}`);
522
+ const truncated = text.length > 200 ? text.substring(0, 200) + "..." : text;
523
+ throw new AuthError(`Token exchange failed (${res.status}): ${truncated}`);
420
524
  }
421
525
  const data = await res.json();
422
526
  return {
@@ -441,7 +545,8 @@ async function refreshUserToken(refreshToken2, profile = "default") {
441
545
  });
442
546
  if (!res.ok) {
443
547
  const text = await res.text();
444
- throw new AuthError(`Token refresh failed (${res.status}): ${text}`);
548
+ const truncated = text.length > 200 ? text.substring(0, 200) + "..." : text;
549
+ throw new AuthError(`Token refresh failed (${res.status}): ${truncated}`);
445
550
  }
446
551
  const data = await res.json();
447
552
  return {
@@ -451,11 +556,28 @@ async function refreshUserToken(refreshToken2, profile = "default") {
451
556
  scope: data.scope
452
557
  };
453
558
  }
454
- function startOAuthCallbackServer(clientId, clientSecret) {
455
- return waitForAuthCode().then(
559
+ function startOAuthCallbackServer(clientId, clientSecret, expectedState) {
560
+ return waitForAuthCode(expectedState).then(
456
561
  (code) => exchangeCodeForToken(code, clientId, clientSecret)
457
562
  );
458
563
  }
564
+ async function revokeToken(token, clientId, clientSecret) {
565
+ try {
566
+ const res = await fetch("https://auth.worksmobile.com/oauth2/v2.0/revoke", {
567
+ method: "POST",
568
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
569
+ body: new URLSearchParams({
570
+ token,
571
+ client_id: clientId,
572
+ client_secret: clientSecret
573
+ }).toString()
574
+ });
575
+ if (!res.ok && process.env["NWORKS_VERBOSE"] === "1") {
576
+ console.error(`[nworks] Token revoke returned ${res.status}`);
577
+ }
578
+ } catch {
579
+ }
580
+ }
459
581
 
460
582
  // src/auth/token-user.ts
461
583
  async function getValidUserToken(profile = "default") {
@@ -508,7 +630,7 @@ function normalizeDateTime(dt) {
508
630
  async function listEvents(fromDateTime, untilDateTime, userId = "me", profile = "default") {
509
631
  const from = encodeURIComponent(fromDateTime);
510
632
  const until = encodeURIComponent(untilDateTime);
511
- const url = `${BASE_URL2}/users/${userId}/calendar/events?fromDateTime=${from}&untilDateTime=${until}`;
633
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events?fromDateTime=${from}&untilDateTime=${until}`;
512
634
  if (process.env["NWORKS_VERBOSE"] === "1") {
513
635
  console.error(`[nworks] GET ${url}`);
514
636
  }
@@ -545,10 +667,10 @@ async function createEvent(opts) {
545
667
  eventComponents: [eventComponent],
546
668
  sendNotification: opts.sendNotification ?? false
547
669
  };
548
- const url = `${BASE_URL2}/users/${userId}/calendar/events`;
670
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events`;
549
671
  if (process.env["NWORKS_VERBOSE"] === "1") {
550
672
  console.error(`[nworks] POST ${url}`);
551
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
673
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
552
674
  }
553
675
  const res = await authedFetch(
554
676
  url,
@@ -566,7 +688,7 @@ async function createEvent(opts) {
566
688
  return await res.json();
567
689
  }
568
690
  async function getEvent(eventId, userId = "me", profile = "default") {
569
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${eventId}`;
691
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(eventId)}`;
570
692
  if (process.env["NWORKS_VERBOSE"] === "1") {
571
693
  console.error(`[nworks] GET ${url}`);
572
694
  }
@@ -609,10 +731,10 @@ async function updateEvent(opts) {
609
731
  eventComponents: [eventComponent],
610
732
  sendNotification: opts.sendNotification ?? false
611
733
  };
612
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${opts.eventId}`;
734
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(opts.eventId)}`;
613
735
  if (process.env["NWORKS_VERBOSE"] === "1") {
614
736
  console.error(`[nworks] PUT ${url}`);
615
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
737
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
616
738
  }
617
739
  const res = await authedFetch(
618
740
  url,
@@ -628,7 +750,7 @@ async function updateEvent(opts) {
628
750
  async function deleteEvent(eventId, userId = "me", sendNotification = false, profile = "default") {
629
751
  const params = new URLSearchParams();
630
752
  params.set("sendNotification", String(sendNotification));
631
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${eventId}?${params.toString()}`;
753
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(eventId)}?${params.toString()}`;
632
754
  if (process.env["NWORKS_VERBOSE"] === "1") {
633
755
  console.error(`[nworks] DELETE ${url}`);
634
756
  }
@@ -638,9 +760,15 @@ async function deleteEvent(eventId, userId = "me", sendNotification = false, pro
638
760
  }
639
761
 
640
762
  // src/api/drive.ts
641
- import { readFile as readFile3, stat } from "fs/promises";
642
- import { basename } from "path";
763
+ import { readFile as readFile3, stat as stat2 } from "fs/promises";
764
+ import { basename as basename2 } from "path";
643
765
  var BASE_URL3 = "https://www.worksapis.com/v1.0";
766
+ var MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
767
+ var ALLOWED_HOSTS = [
768
+ "storage.worksmobile.com",
769
+ "www.worksapis.com",
770
+ "worksapis.com"
771
+ ];
644
772
  async function authedFetch2(url, init, profile) {
645
773
  const token = await getValidUserToken(profile);
646
774
  const headers = new Headers(init.headers);
@@ -662,8 +790,8 @@ async function handleError2(res) {
662
790
  throw new ApiError(code, description, res.status);
663
791
  }
664
792
  async function listFiles(userId = "me", folderId, count = 20, cursor, profile = "default") {
665
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
666
- const path = folderId ? `${base}/${folderId}/children` : base;
793
+ const base = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files`;
794
+ const path = folderId ? `${base}/${sanitizePathSegment(folderId)}/children` : base;
667
795
  const params = new URLSearchParams();
668
796
  params.set("count", String(count));
669
797
  if (cursor) params.set("cursor", cursor);
@@ -677,11 +805,15 @@ async function listFiles(userId = "me", folderId, count = 20, cursor, profile =
677
805
  return { files: data.files ?? [], responseMetaData: data.responseMetaData };
678
806
  }
679
807
  async function uploadFile(localPath, userId = "me", folderId, overwrite = false, profile = "default") {
680
- const fileName = basename(localPath);
681
- const fileStat = await stat(localPath);
808
+ const fileName = basename2(localPath);
809
+ const safeName = sanitizeFileName(fileName);
810
+ const fileStat = await stat2(localPath);
682
811
  const fileSize = fileStat.size;
683
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
684
- const createUrl = folderId ? `${base}/${folderId}` : base;
812
+ if (fileSize > MAX_UPLOAD_SIZE) {
813
+ throw new ApiError("FILE_TOO_LARGE", `File size (${fileSize} bytes) exceeds maximum allowed (${MAX_UPLOAD_SIZE} bytes)`, 413);
814
+ }
815
+ const base = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files`;
816
+ const createUrl = folderId ? `${base}/${sanitizePathSegment(folderId)}` : base;
685
817
  if (process.env["NWORKS_VERBOSE"] === "1") {
686
818
  console.error(`[nworks] POST ${createUrl} (create upload URL)`);
687
819
  }
@@ -696,11 +828,12 @@ async function uploadFile(localPath, userId = "me", folderId, overwrite = false,
696
828
  );
697
829
  if (!createRes.ok) return handleError2(createRes);
698
830
  const { uploadUrl } = await createRes.json();
831
+ validateRedirectUrl(uploadUrl, ALLOWED_HOSTS);
699
832
  const fileBuffer = await readFile3(localPath);
700
833
  const boundary = `----nworks${Date.now()}`;
701
834
  const header = Buffer.from(
702
835
  `--${boundary}\r
703
- Content-Disposition: form-data; name="Filedata"; filename="${fileName}"\r
836
+ Content-Disposition: form-data; name="Filedata"; filename="${safeName}"\r
704
837
  Content-Type: application/octet-stream\r
705
838
  \r
706
839
  `
@@ -726,8 +859,11 @@ Content-Type: application/octet-stream\r
726
859
  }
727
860
  async function uploadBuffer(fileBuffer, fileName, userId = "me", folderId, overwrite = false, profile = "default") {
728
861
  const fileSize = fileBuffer.length;
729
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
730
- const createUrl = folderId ? `${base}/${folderId}` : base;
862
+ if (fileSize > MAX_UPLOAD_SIZE) {
863
+ throw new ApiError("FILE_TOO_LARGE", `File size (${fileSize} bytes) exceeds maximum allowed (${MAX_UPLOAD_SIZE} bytes)`, 413);
864
+ }
865
+ const base = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files`;
866
+ const createUrl = folderId ? `${base}/${sanitizePathSegment(folderId)}` : base;
731
867
  if (process.env["NWORKS_VERBOSE"] === "1") {
732
868
  console.error(`[nworks] POST ${createUrl} (create upload URL for buffer)`);
733
869
  }
@@ -742,10 +878,11 @@ async function uploadBuffer(fileBuffer, fileName, userId = "me", folderId, overw
742
878
  );
743
879
  if (!createRes.ok) return handleError2(createRes);
744
880
  const { uploadUrl } = await createRes.json();
881
+ validateRedirectUrl(uploadUrl, ALLOWED_HOSTS);
745
882
  const boundary = `----nworks${Date.now()}`;
746
883
  const header = Buffer.from(
747
884
  `--${boundary}\r
748
- Content-Disposition: form-data; name="Filedata"; filename="${fileName}"\r
885
+ Content-Disposition: form-data; name="Filedata"; filename="${sanitizeFileName(fileName)}"\r
749
886
  Content-Type: application/octet-stream\r
750
887
  \r
751
888
  `
@@ -770,7 +907,7 @@ Content-Type: application/octet-stream\r
770
907
  return await uploadRes.json();
771
908
  }
772
909
  async function downloadFile(fileId, userId = "me", profile = "default") {
773
- const url = `${BASE_URL3}/users/${userId}/drive/files/${fileId}/download`;
910
+ const url = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files/${sanitizePathSegment(fileId)}/download`;
774
911
  if (process.env["NWORKS_VERBOSE"] === "1") {
775
912
  console.error(`[nworks] GET ${url} (get download URL)`);
776
913
  }
@@ -787,10 +924,11 @@ async function downloadFile(fileId, userId = "me", profile = "default") {
787
924
  if (!redirectRes.ok) return handleError2(redirectRes);
788
925
  throw new ApiError("NO_REDIRECT", "No download URL returned", redirectRes.status);
789
926
  }
927
+ const safeLocation = validateRedirectUrl(location, ALLOWED_HOSTS);
790
928
  if (process.env["NWORKS_VERBOSE"] === "1") {
791
- console.error(`[nworks] GET ${location} (download content)`);
929
+ console.error(`[nworks] GET ${safeLocation} (download content)`);
792
930
  }
793
- const downloadRes = await authedFetch2(location, { method: "GET" }, profile);
931
+ const downloadRes = await fetch(safeLocation, { method: "GET" });
794
932
  if (!downloadRes.ok) return handleError2(downloadRes);
795
933
  const arrayBuffer = await downloadRes.arrayBuffer();
796
934
  const buffer = Buffer.from(arrayBuffer);
@@ -830,7 +968,7 @@ async function handleError3(res) {
830
968
  async function sendMail(opts) {
831
969
  const userId = opts.userId ?? "me";
832
970
  const profile = opts.profile ?? "default";
833
- const url = `${BASE_URL4}/users/${userId}/mail`;
971
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail`;
834
972
  if (process.env["NWORKS_VERBOSE"] === "1") {
835
973
  console.error(`[nworks] POST ${url}`);
836
974
  }
@@ -859,7 +997,7 @@ async function listMails(folderId = 0, userId = "me", count = 30, cursor, isUnre
859
997
  params.set("count", String(count));
860
998
  if (cursor) params.set("cursor", cursor);
861
999
  if (isUnread) params.set("isUnread", "true");
862
- const url = `${BASE_URL4}/users/${userId}/mail/mailfolders/${folderId}/children?${params.toString()}`;
1000
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail/mailfolders/${sanitizePathSegment(String(folderId))}/children?${params.toString()}`;
863
1001
  if (process.env["NWORKS_VERBOSE"] === "1") {
864
1002
  console.error(`[nworks] GET ${url}`);
865
1003
  }
@@ -875,7 +1013,7 @@ async function listMails(folderId = 0, userId = "me", count = 30, cursor, isUnre
875
1013
  };
876
1014
  }
877
1015
  async function readMail(mailId, userId = "me", profile = "default") {
878
- const url = `${BASE_URL4}/users/${userId}/mail/${mailId}`;
1016
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail/${sanitizePathSegment(String(mailId))}`;
879
1017
  if (process.env["NWORKS_VERBOSE"] === "1") {
880
1018
  console.error(`[nworks] GET ${url}`);
881
1019
  }
@@ -908,7 +1046,7 @@ async function handleError4(res) {
908
1046
  }
909
1047
  async function resolveUserId(userId, profile) {
910
1048
  if (userId !== "me") return userId;
911
- const url = `${BASE_URL5}/users/me`;
1049
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}`;
912
1050
  const res = await authedFetch4(url, { method: "GET" }, profile);
913
1051
  if (!res.ok) return handleError4(res);
914
1052
  const data = await res.json();
@@ -923,7 +1061,7 @@ async function listTasks(categoryId = "default", userId = "me", count = 50, curs
923
1061
  params.set("count", String(count));
924
1062
  params.set("status", status);
925
1063
  if (cursor) params.set("cursor", cursor);
926
- const url = `${BASE_URL5}/users/${userId}/tasks?${params.toString()}`;
1064
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}/tasks?${params.toString()}`;
927
1065
  if (process.env["NWORKS_VERBOSE"] === "1") {
928
1066
  console.error(`[nworks] GET ${url}`);
929
1067
  }
@@ -947,10 +1085,10 @@ async function createTask(opts) {
947
1085
  };
948
1086
  if (opts.dueDate) body.dueDate = opts.dueDate;
949
1087
  if (opts.categoryId) body.categoryId = opts.categoryId;
950
- const url = `${BASE_URL5}/users/${userId}/tasks`;
1088
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}/tasks`;
951
1089
  if (process.env["NWORKS_VERBOSE"] === "1") {
952
1090
  console.error(`[nworks] POST ${url}`);
953
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1091
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
954
1092
  }
955
1093
  const res = await authedFetch4(
956
1094
  url,
@@ -973,7 +1111,7 @@ async function updateTask(opts) {
973
1111
  if (opts.title !== void 0) body.title = opts.title;
974
1112
  if (opts.content !== void 0) body.content = opts.content;
975
1113
  if (opts.dueDate !== void 0) body.dueDate = opts.dueDate;
976
- const url = `${BASE_URL5}/tasks/${opts.taskId}`;
1114
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(opts.taskId)}`;
977
1115
  if (process.env["NWORKS_VERBOSE"] === "1") {
978
1116
  console.error(`[nworks] PATCH ${url}`);
979
1117
  }
@@ -990,7 +1128,7 @@ async function updateTask(opts) {
990
1128
  return await res.json();
991
1129
  }
992
1130
  async function completeTask(taskId, profile = "default") {
993
- const url = `${BASE_URL5}/tasks/${taskId}/complete`;
1131
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}/complete`;
994
1132
  if (process.env["NWORKS_VERBOSE"] === "1") {
995
1133
  console.error(`[nworks] POST ${url}`);
996
1134
  }
@@ -1003,7 +1141,7 @@ async function completeTask(taskId, profile = "default") {
1003
1141
  if (!res.ok) return handleError4(res);
1004
1142
  }
1005
1143
  async function incompleteTask(taskId, profile = "default") {
1006
- const url = `${BASE_URL5}/tasks/${taskId}/incomplete`;
1144
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}/incomplete`;
1007
1145
  if (process.env["NWORKS_VERBOSE"] === "1") {
1008
1146
  console.error(`[nworks] POST ${url}`);
1009
1147
  }
@@ -1016,7 +1154,7 @@ async function incompleteTask(taskId, profile = "default") {
1016
1154
  if (!res.ok) return handleError4(res);
1017
1155
  }
1018
1156
  async function deleteTask(taskId, profile = "default") {
1019
- const url = `${BASE_URL5}/tasks/${taskId}`;
1157
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}`;
1020
1158
  if (process.env["NWORKS_VERBOSE"] === "1") {
1021
1159
  console.error(`[nworks] DELETE ${url}`);
1022
1160
  }
@@ -1070,7 +1208,7 @@ async function listBoards(count = 20, cursor, profile = "default") {
1070
1208
  if (!res.ok) return handleError5(res);
1071
1209
  const text = await res.text();
1072
1210
  if (process.env["NWORKS_VERBOSE"] === "1") {
1073
- console.error(`[nworks] Response: ${text}`);
1211
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1074
1212
  }
1075
1213
  const data = safeParseJson(text);
1076
1214
  return { boards: data.boards ?? [], responseMetaData: data.responseMetaData };
@@ -1079,7 +1217,7 @@ async function listPosts(boardId, count = 20, cursor, profile = "default") {
1079
1217
  const params = new URLSearchParams();
1080
1218
  params.set("count", String(count));
1081
1219
  if (cursor) params.set("cursor", cursor);
1082
- const url = `${BASE_URL6}/boards/${boardId}/posts?${params.toString()}`;
1220
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(boardId)}/posts?${params.toString()}`;
1083
1221
  if (process.env["NWORKS_VERBOSE"] === "1") {
1084
1222
  console.error(`[nworks] GET ${url}`);
1085
1223
  }
@@ -1087,13 +1225,13 @@ async function listPosts(boardId, count = 20, cursor, profile = "default") {
1087
1225
  if (!res.ok) return handleError5(res);
1088
1226
  const text = await res.text();
1089
1227
  if (process.env["NWORKS_VERBOSE"] === "1") {
1090
- console.error(`[nworks] Response: ${text}`);
1228
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1091
1229
  }
1092
1230
  const data = safeParseJson(text);
1093
1231
  return { posts: data.posts ?? [], responseMetaData: data.responseMetaData };
1094
1232
  }
1095
1233
  async function readPost(boardId, postId, profile = "default") {
1096
- const url = `${BASE_URL6}/boards/${boardId}/posts/${postId}`;
1234
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(boardId)}/posts/${sanitizePathSegment(postId)}`;
1097
1235
  if (process.env["NWORKS_VERBOSE"] === "1") {
1098
1236
  console.error(`[nworks] GET ${url}`);
1099
1237
  }
@@ -1101,7 +1239,7 @@ async function readPost(boardId, postId, profile = "default") {
1101
1239
  if (!res.ok) return handleError5(res);
1102
1240
  const text = await res.text();
1103
1241
  if (process.env["NWORKS_VERBOSE"] === "1") {
1104
- console.error(`[nworks] Response: ${text}`);
1242
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1105
1243
  }
1106
1244
  return safeParseJson(text);
1107
1245
  }
@@ -1113,10 +1251,10 @@ async function createPost(opts) {
1113
1251
  };
1114
1252
  if (opts.enableComment !== void 0) body.enableComment = opts.enableComment;
1115
1253
  if (opts.sendNotifications !== void 0) body.sendNotifications = opts.sendNotifications;
1116
- const url = `${BASE_URL6}/boards/${opts.boardId}/posts`;
1254
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(opts.boardId)}/posts`;
1117
1255
  if (process.env["NWORKS_VERBOSE"] === "1") {
1118
1256
  console.error(`[nworks] POST ${url}`);
1119
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1257
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1120
1258
  }
1121
1259
  const res = await authedFetch5(
1122
1260
  url,
@@ -1130,7 +1268,7 @@ async function createPost(opts) {
1130
1268
  if (res.status === 201 || res.ok) {
1131
1269
  const text = await res.text();
1132
1270
  if (process.env["NWORKS_VERBOSE"] === "1") {
1133
- console.error(`[nworks] Response: ${text}`);
1271
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1134
1272
  }
1135
1273
  return safeParseJson(text);
1136
1274
  }
@@ -1506,7 +1644,7 @@ OAuth Redirect URI: http://localhost:9876/callback`,
1506
1644
  success: true,
1507
1645
  message: "\uC778\uC99D \uC815\uBCF4\uAC00 \uC800\uC7A5\uB418\uC5C8\uC2B5\uB2C8\uB2E4.",
1508
1646
  nextSteps,
1509
- clientId,
1647
+ clientId: mask(clientId),
1510
1648
  clientSecret: `${mask(resolvedSecret)} (\uD658\uACBD\uBCC0\uC218)`,
1511
1649
  serviceAccount: serviceAccount ?? null,
1512
1650
  privateKeyPath: resolvedPrivateKeyPath ? `${mask(resolvedPrivateKeyPath)} (\uD658\uACBD\uBCC0\uC218)` : null,
@@ -1642,7 +1780,17 @@ OAuth Redirect URI: http://localhost:9876/callback`,
1642
1780
  sendNotification: z.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
1643
1781
  userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
1644
1782
  },
1645
- async ({ summary, start, end, timeZone, description, location, attendees, sendNotification, userId }) => {
1783
+ async ({
1784
+ summary,
1785
+ start,
1786
+ end,
1787
+ timeZone,
1788
+ description,
1789
+ location,
1790
+ attendees,
1791
+ sendNotification,
1792
+ userId
1793
+ }) => {
1646
1794
  try {
1647
1795
  const result = await createEvent({
1648
1796
  summary,
@@ -1681,7 +1829,17 @@ OAuth Redirect URI: http://localhost:9876/callback`,
1681
1829
  sendNotification: z.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
1682
1830
  userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
1683
1831
  },
1684
- async ({ eventId, summary, start, end, timeZone, description, location, sendNotification, userId }) => {
1832
+ async ({
1833
+ eventId,
1834
+ summary,
1835
+ start,
1836
+ end,
1837
+ timeZone,
1838
+ description,
1839
+ location,
1840
+ sendNotification,
1841
+ userId
1842
+ }) => {
1685
1843
  try {
1686
1844
  await updateEvent({
1687
1845
  eventId,
@@ -1794,11 +1952,12 @@ OAuth Redirect URI: http://localhost:9876/callback`,
1794
1952
  overwrite ?? false
1795
1953
  );
1796
1954
  } else if (filePath) {
1955
+ const safePath = validateLocalPath(filePath);
1797
1956
  if (process.env["NWORKS_VERBOSE"] === "1") {
1798
- console.error(`[nworks] MCP upload: filePath=${filePath}`);
1957
+ console.error(`[nworks] MCP upload: filePath=${safePath}`);
1799
1958
  }
1800
1959
  result = await uploadFile(
1801
- filePath,
1960
+ safePath,
1802
1961
  userId ?? "me",
1803
1962
  folderId,
1804
1963
  overwrite ?? false
@@ -1813,10 +1972,11 @@ OAuth Redirect URI: http://localhost:9876/callback`,
1813
1972
  content: [{ type: "text", text: JSON.stringify({ success: true, ...result }) }]
1814
1973
  };
1815
1974
  } catch (err) {
1816
- const error = err;
1817
- const detail = process.env["NWORKS_VERBOSE"] === "1" ? ` | stack: ${error.stack}` : "";
1975
+ if (process.env["NWORKS_VERBOSE"] === "1") {
1976
+ console.error(`[nworks] drive upload error: ${err.stack}`);
1977
+ }
1818
1978
  return {
1819
- content: [{ type: "text", text: `${mcpErrorHint(err, "drive.upload")}${detail}` }],
1979
+ content: [{ type: "text", text: mcpErrorHint(err, "drive.upload") }],
1820
1980
  isError: true
1821
1981
  };
1822
1982
  }
@@ -1841,7 +2001,10 @@ OAuth Redirect URI: http://localhost:9876/callback`,
1841
2001
  if (outputDir) {
1842
2002
  const { writeFile: writeFile2 } = await import("fs/promises");
1843
2003
  const { join: join2 } = await import("path");
1844
- const outPath = join2(outputDir, fileName);
2004
+ const safeDir = validateLocalPath(outputDir);
2005
+ const safeName = sanitizeFileName(fileName);
2006
+ const outPath = join2(safeDir, safeName);
2007
+ validateLocalPath(outPath, safeDir);
1845
2008
  await writeFile2(outPath, result.buffer);
1846
2009
  return {
1847
2010
  content: [{ type: "text", text: JSON.stringify({ success: true, fileName, path: outPath, size: result.buffer.length }) }]
@@ -2262,9 +2425,9 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2262
2425
  const existingScopes = existingToken?.scope?.split(" ").filter(Boolean) ?? [];
2263
2426
  const requestedScopes = expandScopes((scope ?? DEFAULT_SCOPE).split(" ").filter(Boolean));
2264
2427
  const mergedScopes = [.../* @__PURE__ */ new Set([...existingScopes, ...requestedScopes])].join(" ");
2265
- const state = Math.random().toString(36).substring(2);
2428
+ const state = generateSecureState();
2266
2429
  const authorizeUrl = buildAuthorizeUrl(creds.clientId, mergedScopes, state);
2267
- startOAuthCallbackServer(creds.clientId, creds.clientSecret).then(
2430
+ startOAuthCallbackServer(creds.clientId, creds.clientSecret, state).then(
2268
2431
  (token) => saveUserToken({
2269
2432
  accessToken: token.accessToken,
2270
2433
  refreshToken: token.refreshToken,
@@ -2309,9 +2472,10 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2309
2472
  const userToken = await loadUserToken();
2310
2473
  const isValid = token ? token.expiresAt > Date.now() / 1e3 : false;
2311
2474
  const userTokenValid = userToken ? userToken.expiresAt > Date.now() / 1e3 : false;
2475
+ const mask = (s) => s.length <= 4 ? "****" : `****${s.slice(-4)}`;
2312
2476
  const info = {
2313
2477
  serviceAccount: creds.serviceAccount ?? null,
2314
- clientId: creds.clientId,
2478
+ clientId: mask(creds.clientId),
2315
2479
  botId: creds.botId ?? null,
2316
2480
  tokenValid: isValid,
2317
2481
  userOAuth: userToken ? { valid: userTokenValid, scope: userToken.scope } : null
@@ -2333,6 +2497,18 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2333
2497
  {},
2334
2498
  async () => {
2335
2499
  try {
2500
+ try {
2501
+ const creds = await loadCredentials();
2502
+ const token = await loadToken();
2503
+ const userToken = await loadUserToken();
2504
+ if (token?.accessToken) {
2505
+ await revokeToken(token.accessToken, creds.clientId, creds.clientSecret);
2506
+ }
2507
+ if (userToken?.refreshToken) {
2508
+ await revokeToken(userToken.refreshToken, creds.clientId, creds.clientSecret);
2509
+ }
2510
+ } catch {
2511
+ }
2336
2512
  await clearCredentials();
2337
2513
  return {
2338
2514
  content: [
@@ -2360,8 +2536,21 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2360
2536
  async () => {
2361
2537
  try {
2362
2538
  const results = await runChecks("default");
2539
+ const maskedResults = results.map((r) => {
2540
+ if (r.check === "credentials" && r.status === "OK") {
2541
+ return { ...r, detail: r.detail.replace(/clientId: .+/, "clientId: ****") };
2542
+ }
2543
+ if (r.check === "privateKey" && r.status === "OK") {
2544
+ return { ...r, detail: "OK (path hidden)" };
2545
+ }
2546
+ if (r.check === "serviceAccount" && r.status === "OK") {
2547
+ const masked = r.detail.length <= 4 ? "****" : `****${r.detail.slice(-4)}`;
2548
+ return { ...r, detail: masked };
2549
+ }
2550
+ return r;
2551
+ });
2363
2552
  return {
2364
- content: [{ type: "text", text: JSON.stringify(results) }]
2553
+ content: [{ type: "text", text: JSON.stringify(maskedResults) }]
2365
2554
  };
2366
2555
  } catch (err) {
2367
2556
  return {