Files
Netcatty/electron/bridges/googleAuthBridge.cjs
T
libalpm64 4090483738 Fix Security Issues (#799)
* chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder

Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) and [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder). These dependencies needed to be updated together.

Updates `fast-xml-parser` from 5.3.4 to 5.5.8
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.4...v5.5.8)

Updates `@aws-sdk/xml-builder` from 3.972.4 to 3.972.18
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/xml-builder/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/xml-builder)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.5.8
  dependency-type: indirect
- dependency-name: "@aws-sdk/xml-builder"
  dependency-version: 3.972.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0

Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump hono from 4.12.7 to 4.12.14

Bumps [hono](https://github.com/honojs/hono) from 4.12.7 to 4.12.14.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.7...v4.12.14)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump vite from 7.3.1 to 7.3.2

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump flatted from 3.3.3 to 3.4.2

Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump minimatch from 3.1.2 to 3.1.5

Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump lodash from 4.17.23 to 4.18.1

Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump @hono/node-server from 1.19.11 to 1.19.14

Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.11 to 1.19.14.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.11...v1.19.14)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump rollup from 4.57.1 to 4.60.2

Bumps [rollup](https://github.com/rollup/rollup) from 4.57.1 to 4.60.2.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.60.2)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.60.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump electron from 40.1.0 to 40.8.5

Bumps [electron](https://github.com/electron/electron) from 40.1.0 to 40.8.5.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v40.1.0...v40.8.5)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 40.8.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump path-to-regexp from 8.3.0 to 8.4.2

Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from 8.3.0 to 8.4.2.
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v8.3.0...v8.4.2)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-version: 8.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump picomatch from 2.3.1 to 2.3.2

Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump yaml from 2.8.2 to 2.8.3

Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.2 to 2.8.3.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.2...v2.8.3)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 2.8.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.13

Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.11 to 0.8.13.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.13)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump brace-expansion from 1.1.12 to 1.1.14

Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.14.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.14)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump tar from 7.5.7 to 7.5.13

Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.13.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.13)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Security Fixes

Security fixes:
Added input validation for uncontrolled command lines.
Added Proper Shell Escaping for useTerminalAutocomplete
Fixed 4 race condition alerts by atomic stat+read(s) without following symlinks.

Misc:
Use Crypto randomness instead of Math.random() (Not a security issue but convenient)

* Fix OS quirk fallbacks

* Review fix

- use lstat before open to skip FIFO/devices early to prevent blocks
- SFTP skip UUID tag could be dubiously long

* allow symlinks alongside regular files.

* Use acutal target size for reading

* Fix Destructed import / fix to use full shellEscape charset

- Destructed import
- Guard now matches full shellEscape charset

* Supress Codex complaints

Replaced manual fd.read with fs.promises.readFile(fd) to ensure complete file reads to EOF.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 01:41:26 +08:00

418 lines
14 KiB
JavaScript

/**
* Google OAuth Bridge (main process)
*
* Renderer fetches to Google's OAuth token endpoint can be blocked by CORS.
* This bridge proxies Google token exchange/refresh and userinfo via the main process.
*/
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
const GOOGLE_DRIVE_API = "https://www.googleapis.com/drive/v3";
const GOOGLE_DRIVE_UPLOAD_API = "https://www.googleapis.com/upload/drive/v3";
const DEFAULT_SYNC_FILE_NAME = "netcatty-vault.json";
const { randomUUID } = require("node:crypto");
const isNonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
const describeNetworkError = (err) => {
const msg = err?.message || String(err);
const cause = err?.cause;
const causeCode = cause?.code ? ` (${cause.code})` : "";
const causeMsg = cause?.message ? `: ${cause.message}` : "";
return `${msg}${causeCode}${causeMsg}`.trim();
};
const safeJsonParse = (text) => {
try {
return JSON.parse(text);
} catch {
return null;
}
};
const describeGoogleApiError = (status, text) => {
const data = safeJsonParse(text);
const msg =
data?.error?.message ||
data?.error_description ||
data?.error ||
(typeof text === "string" && text.trim().length ? text.trim() : "Unknown error");
return `${status} - ${String(msg).slice(0, 300)}`;
};
/**
* @param {Electron.IpcMain} ipcMain
* @param {import('electron')=} electronModule
*/
function registerHandlers(ipcMain, electronModule) {
const fetchImpl =
electronModule?.net?.fetch ? electronModule.net.fetch.bind(electronModule.net) : fetch;
ipcMain.handle("netcatty:google:oauth:exchange", async (_event, payload) => {
const clientId = payload?.clientId;
const clientSecret = payload?.clientSecret;
const code = payload?.code;
const codeVerifier = payload?.codeVerifier;
const redirectUri = payload?.redirectUri;
if (!isNonEmptyString(clientId)) throw new Error("Missing Google clientId");
if (!isNonEmptyString(code)) throw new Error("Missing authorization code");
if (!isNonEmptyString(codeVerifier)) throw new Error("Missing codeVerifier");
if (!isNonEmptyString(redirectUri)) throw new Error("Missing redirectUri");
const body = new URLSearchParams({
client_id: clientId,
code,
code_verifier: codeVerifier,
grant_type: "authorization_code",
redirect_uri: redirectUri,
});
if (isNonEmptyString(clientSecret)) {
body.set("client_secret", clientSecret);
}
let res;
try {
res = await fetchImpl(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
} catch (err) {
throw new Error(
`Google token exchange network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
let data;
try {
data = JSON.parse(text);
} catch {
data = { error: text };
}
throw new Error(`Google token exchange failed: ${data.error_description || data.error || res.status}`);
}
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Google token exchange invalid JSON: ${text.slice(0, 200)}`);
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + (data.expires_in || 0) * 1000,
tokenType: data.token_type,
scope: data.scope,
};
});
ipcMain.handle("netcatty:google:oauth:refresh", async (_event, payload) => {
const clientId = payload?.clientId;
const clientSecret = payload?.clientSecret;
const refreshToken = payload?.refreshToken;
if (!isNonEmptyString(clientId)) throw new Error("Missing Google clientId");
if (!isNonEmptyString(refreshToken)) throw new Error("Missing refreshToken");
const body = new URLSearchParams({
client_id: clientId,
refresh_token: refreshToken,
grant_type: "refresh_token",
});
if (isNonEmptyString(clientSecret)) {
body.set("client_secret", clientSecret);
}
let res;
try {
res = await fetchImpl(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
} catch (err) {
throw new Error(
`Google token refresh network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
let data;
try {
data = JSON.parse(text);
} catch {
data = { error: text };
}
throw new Error(`Google token refresh failed: ${data.error_description || data.error || res.status}`);
}
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Google token refresh invalid JSON: ${text.slice(0, 200)}`);
}
return {
accessToken: data.access_token,
refreshToken,
expiresAt: Date.now() + (data.expires_in || 0) * 1000,
tokenType: data.token_type,
scope: data.scope,
};
});
ipcMain.handle("netcatty:google:oauth:userinfo", async (_event, payload) => {
const accessToken = payload?.accessToken;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
let res;
try {
res = await fetchImpl(GOOGLE_USERINFO_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
} catch (err) {
throw new Error(
`Google userinfo network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
throw new Error(`Google userinfo failed: ${res.status} - ${text.slice(0, 200)}`);
}
try {
return JSON.parse(text);
} catch {
throw new Error(`Google userinfo invalid JSON: ${text.slice(0, 200)}`);
}
});
// Google Drive API (appDataFolder) - proxied to avoid CORS/COEP issues in renderer
ipcMain.handle("netcatty:google:drive:findSyncFile", async (_event, payload) => {
const accessToken = payload?.accessToken;
const fileName = isNonEmptyString(payload?.fileName) ? payload.fileName : DEFAULT_SYNC_FILE_NAME;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
const params = new URLSearchParams({
spaces: "appDataFolder",
q: `name = '${fileName}'`,
fields: "files(id, name, modifiedTime)",
});
let res;
try {
res = await fetchImpl(`${GOOGLE_DRIVE_API}/files?${params.toString()}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
} catch (err) {
throw new Error(
`Google Drive network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
if (res.status === 403) {
throw new Error("Google Drive API not enabled. Please enable it in Google Cloud Console.");
}
if (res.status === 401) {
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
}
throw new Error(`Google Drive API error: ${describeGoogleApiError(res.status, text)}`);
}
const data = safeJsonParse(text);
const fileId = data?.files?.[0]?.id || null;
return { fileId };
});
ipcMain.handle("netcatty:google:drive:createSyncFile", async (_event, payload) => {
const accessToken = payload?.accessToken;
const fileName = isNonEmptyString(payload?.fileName) ? payload.fileName : DEFAULT_SYNC_FILE_NAME;
const syncedFile = payload?.syncedFile;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
if (!syncedFile) throw new Error("Missing syncedFile");
const boundary = `----netcatty_${randomUUID()}`;
const metadata = JSON.stringify({
name: fileName,
parents: ["appDataFolder"],
});
const content = JSON.stringify(syncedFile, null, 2);
const body =
`--${boundary}\r\n` +
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
`${metadata}\r\n` +
`--${boundary}\r\n` +
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
`${content}\r\n` +
`--${boundary}--\r\n`;
let res;
try {
res = await fetchImpl(`${GOOGLE_DRIVE_UPLOAD_API}/files?uploadType=multipart`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"Content-Type": `multipart/related; boundary=${boundary}`,
},
body,
});
} catch (err) {
throw new Error(
`Google Drive upload network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
if (res.status === 403) {
throw new Error("Google Drive API not enabled. Please enable it in Google Cloud Console.");
}
if (res.status === 401) {
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
}
throw new Error(`Google Drive upload error: ${describeGoogleApiError(res.status, text)}`);
}
const data = safeJsonParse(text);
const fileId = data?.id;
if (!isNonEmptyString(fileId)) {
throw new Error(`Google Drive upload invalid response: ${text.slice(0, 200)}`);
}
return { fileId };
});
ipcMain.handle("netcatty:google:drive:updateSyncFile", async (_event, payload) => {
const accessToken = payload?.accessToken;
const fileId = payload?.fileId;
const syncedFile = payload?.syncedFile;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
if (!isNonEmptyString(fileId)) throw new Error("Missing fileId");
if (!syncedFile) throw new Error("Missing syncedFile");
let res;
try {
res = await fetchImpl(`${GOOGLE_DRIVE_UPLOAD_API}/files/${encodeURIComponent(fileId)}?uploadType=media`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(syncedFile, null, 2),
});
} catch (err) {
throw new Error(
`Google Drive update network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
if (res.status === 403) {
throw new Error("Google Drive API not enabled. Please enable it in Google Cloud Console.");
}
if (res.status === 401) {
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
}
throw new Error(`Google Drive update error: ${describeGoogleApiError(res.status, text)}`);
}
return { ok: true };
});
ipcMain.handle("netcatty:google:drive:downloadSyncFile", async (_event, payload) => {
const accessToken = payload?.accessToken;
const fileId = payload?.fileId;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
if (!isNonEmptyString(fileId)) throw new Error("Missing fileId");
let res;
try {
res = await fetchImpl(`${GOOGLE_DRIVE_API}/files/${encodeURIComponent(fileId)}?alt=media`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
} catch (err) {
throw new Error(
`Google Drive download network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
if (res.status === 404) return { syncedFile: null };
if (res.status === 401) {
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
}
throw new Error(`Google Drive download error: ${describeGoogleApiError(res.status, text)}`);
}
const data = safeJsonParse(text);
if (!data) {
throw new Error(`Google Drive download invalid JSON: ${text.slice(0, 200)}`);
}
return { syncedFile: data };
});
ipcMain.handle("netcatty:google:drive:deleteSyncFile", async (_event, payload) => {
const accessToken = payload?.accessToken;
const fileId = payload?.fileId;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
if (!isNonEmptyString(fileId)) throw new Error("Missing fileId");
let res;
try {
res = await fetchImpl(`${GOOGLE_DRIVE_API}/files/${encodeURIComponent(fileId)}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
} catch (err) {
throw new Error(
`Google Drive delete network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
);
}
if (!res.ok && res.status !== 404) {
const text = await res.text();
if (res.status === 401) {
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
}
throw new Error(`Google Drive delete error: ${describeGoogleApiError(res.status, text)}`);
}
return { ok: true };
});
}
module.exports = { registerHandlers };