Skip to content

Commit ba2369c

Browse files
committed
fix(security): wave 3 — rendererLog hardening, binary SHA256, Sentry env tag
- main.ts: rendererLog now caps each arg at 4 KiB and redacts when the serialized payload matches credential-like patterns (Authorization, bearer, jwt, password). Earlier renderer XSS could pour the entire localStorage (incl. daemon JWT) into main.log via one IPC call. - BinaryCatalog.cs: BinaryRelease record gained optional Sha256 field. - BinaryDownloader.cs: verifies Sha256 before publishing the archive under its final name. Mismatch deletes the temp file and throws. Missing Sha256 logs a WARN — current static catalog has unhashed legacy rows; once those are populated this should hard-fail. - src/main.ts (renderer): Sentry now tags events with environment (production/development) + release nks-wdc-electron@<version>, matching the main-process Sentry init.
1 parent f3d685b commit ba2369c

5 files changed

Lines changed: 88 additions & 3 deletions

File tree

src/daemon/NKS.WebDevConsole.Core/Models/BinaryCatalog.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ public sealed record BinaryRelease(
1212
string Arch, // "x64", "arm64"
1313
string ArchiveType, // "zip", "tar.gz", "tar.xz"
1414
string Source, // "apachelounge", "php.net", "dev.mysql.com" — for attribution
15-
string? UserAgent = null // some sites (apachelounge) require browser UA
15+
string? UserAgent = null, // some sites (apachelounge) require browser UA
16+
// Lowercase hex SHA-256 of the archive bytes. Optional today (the
17+
// static catalog ships entries without hashes) but the BinaryDownloader
18+
// verifies whenever a value is present, so populating this field on
19+
// an existing entry retroactively hardens the download against MITM
20+
// / cache-poisoning of the upstream CDN. New entries SHOULD include it.
21+
string? Sha256 = null
1622
);
1723

1824
/// <summary>

src/daemon/NKS.WebDevConsole.Daemon/Binaries/BinaryDownloader.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,47 @@ public async Task<string> DownloadAsync(
7272
}
7373
}
7474

75+
// Integrity check BEFORE publishing the archive under its final
76+
// name — otherwise a poisoned upstream CDN/mirror could swap the
77+
// binary underneath us and BinaryManager would cheerfully extract
78+
// and execute a backdoored mysqld/php-cgi/httpd. When the catalog
79+
// entry carries an Sha256 we MUST match it; if it doesn't, log a
80+
// WARN so support bundles surface the gap and the maintainer can
81+
// backfill the entry (current static catalog has many unhashed
82+
// legacy rows — once those are populated this should hard-fail
83+
// on missing Sha256 too, mirroring the plugin downloader).
84+
if (!string.IsNullOrWhiteSpace(release.Sha256))
85+
{
86+
var actual = await ComputeSha256Async(tempPath, ct);
87+
if (!actual.Equals(release.Sha256, StringComparison.OrdinalIgnoreCase))
88+
{
89+
try { File.Delete(tempPath); } catch { /* best-effort cleanup */ }
90+
throw new InvalidOperationException(
91+
$"SHA256 mismatch for {release.App} {release.Version}: " +
92+
$"expected {release.Sha256}, got {actual}. Refusing to install.");
93+
}
94+
_logger.LogInformation("SHA256 verified for {App} {Version}", release.App, release.Version);
95+
}
96+
else
97+
{
98+
_logger.LogWarning(
99+
"No SHA256 in catalog for {App} {Version} — install proceeds without integrity check",
100+
release.App, release.Version);
101+
}
102+
75103
File.Move(tempPath, archivePath, overwrite: true);
76104
_logger.LogInformation("Downloaded {App} {Version} ({Size} bytes)", release.App, release.Version, new FileInfo(archivePath).Length);
77105
return archivePath;
78106
}
79107

108+
private static async Task<string> ComputeSha256Async(string path, CancellationToken ct)
109+
{
110+
using var stream = File.OpenRead(path);
111+
using var sha = System.Security.Cryptography.SHA256.Create();
112+
var hash = await sha.ComputeHashAsync(stream, ct);
113+
return Convert.ToHexString(hash).ToLowerInvariant();
114+
}
115+
80116
public async Task<string> ExtractAsync(
81117
string archivePath,
82118
string destinationDir,

src/frontend/electron/main.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1265,10 +1265,45 @@ ipcMain.handle('reveal-in-folder', async (_evt, targetPath: string) => {
12651265
// posts `{ level, args }` via this channel; we fan it into the main
12661266
// logger at the matching level. Dropped messages fail-closed (return
12671267
// false) so the renderer doesn't block on unresponsive main.
1268+
// Cap on the serialized payload size — renderer XSS could otherwise dump
1269+
// the entire localStorage (including the daemon JWT) into main.log via a
1270+
// single rendererLog() call. 4 KiB is enough for a normal stack trace +
1271+
// a few breadcrumb objects but cuts off attacker dumps long before any
1272+
// useful credential material lands on disk.
1273+
const RENDERER_LOG_MAX_BYTES = 4096
1274+
// Substrings whose presence in a log argument signals likely credential
1275+
// material. Matched against each argument's JSON-stringified form; if a
1276+
// match hits we drop that argument entirely and replace with a redaction
1277+
// marker so the surrounding context still lands in the log.
1278+
const RENDERER_LOG_REDACT_PATTERNS = [
1279+
/authorization\s*[:=]/i,
1280+
/\btoken\s*[=:]/i,
1281+
/bearer\s+[A-Za-z0-9._\-+/=]{16,}/i,
1282+
/\bjwt\b/i,
1283+
/password\s*[:=]/i,
1284+
]
1285+
1286+
function safeRendererLogArg(arg: unknown): unknown {
1287+
let serialized: string
1288+
try {
1289+
serialized = typeof arg === 'string' ? arg : JSON.stringify(arg)
1290+
} catch {
1291+
return '[unserializable]'
1292+
}
1293+
if (serialized.length > RENDERER_LOG_MAX_BYTES) {
1294+
return `${serialized.slice(0, RENDERER_LOG_MAX_BYTES)}…[truncated ${serialized.length - RENDERER_LOG_MAX_BYTES}B]`
1295+
}
1296+
for (const pat of RENDERER_LOG_REDACT_PATTERNS) {
1297+
if (pat.test(serialized)) return '[redacted: contained credential-like pattern]'
1298+
}
1299+
return arg
1300+
}
1301+
12681302
ipcMain.handle('renderer-log', (_evt, payload: { level?: string; args?: unknown[] }) => {
12691303
try {
12701304
const level = (payload?.level ?? 'info').toLowerCase()
1271-
const args = Array.isArray(payload?.args) ? payload.args : []
1305+
const rawArgs = Array.isArray(payload?.args) ? payload.args : []
1306+
const args = rawArgs.map(safeRendererLogArg)
12721307
const fn = (log as unknown as Record<string, (...a: unknown[]) => void>)[level] ?? log.info
12731308
fn('[renderer]', ...args)
12741309
return true

src/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@nks-hub/webdev-console",
3-
"version": "0.2.24",
3+
"version": "0.2.25",
44
"description": "Unified local development environment for Windows, macOS, and Linux — manage Apache/Nginx, PHP versions, MySQL/MariaDB, and SSL from one interface.",
55
"author": {
66
"name": "NKS Hub",

src/frontend/src/main.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ try {
7373
// need the base init to wire up the IPC scope + breadcrumbs.
7474
Sentry.init({
7575
...(VITE_SENTRY_DSN ? { dsn: VITE_SENTRY_DSN } : {}),
76+
// Tag every renderer event with the same environment label as the
77+
// main process Sentry init (production for packaged builds, otherwise
78+
// development). Lets Sentry filter dashboards by deployment without
79+
// operators having to remember the URL/release-tag heuristic. Vite's
80+
// `import.meta.env.PROD` is true for `vite build` output, false for
81+
// `vite dev` — exactly the discriminator we want.
82+
environment: import.meta.env.PROD ? 'production' : 'development',
83+
release: typeof window.__APP_VERSION__ === 'string' ? `nks-wdc-electron@${window.__APP_VERSION__}` : undefined,
7684
// Drop benign daemon-disconnect noise. `Failed to fetch` from the
7785
// shared json() helper in api/daemon.ts fires every time the daemon
7886
// is restarting (factory reset, SSO callback racing the port file,

0 commit comments

Comments
 (0)