fix: preserve charset detection and block CGNAT range in parse-url SSRF fix

Follow-up to the multi-reviewer review of the SSRF fix:

- Restore charset handling lost when switching from extract() to
  response.text(): non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common on CJK
  sites this project targets) decoded as mojibake. Now read the body as
  bytes, detect charset from Content-Type / <meta charset>, and decode
  with TextDecoder before extractFromHtml.
- Wrap extractFromHtml in try/catch: it throws (not returns null) on
  empty/non-HTML bodies, which previously surfaced as a 500 instead of the
  intended 400.
- Add 100.64.0.0/10 (RFC 6598 CGNAT) to isPrivateIp; it is routable inside
  some cloud internal networks and was a residual SSRF target.
- Add tests for CGNAT, its boundaries, 0.0.0.0, and DNS-resolved IPv6.
This commit is contained in:
dayuan.jiang
2026-06-27 18:26:23 +09:00
parent 73862f6108
commit 20e5fc8749
5 changed files with 180 additions and 2 deletions
+118
View File
@@ -0,0 +1,118 @@
import fs from "node:fs"
import path from "node:path"
import { app } from "electron"
/**
* Recover chat history / templates that were orphaned by an origin change.
*
* Browser storage (IndexedDB, localStorage) is scoped per origin
* (scheme://host:port). Older releases served the app from
* `http://localhost:61337`; v0.4.14+ switched the host to `127.0.0.1`
* (and the default port to 13370), which is a *different* origin. The old
* data is still on disk but the new origin can't see it, so users perceive
* their chat history and template library as gone (issue #875).
*
* Chromium stores each origin's IndexedDB databases in a single directory
* named `http_<host>_<port>.indexeddb.leveldb`. All IndexedDB databases for
* one origin (both `next-ai-drawio` sessions and `next-ai-drawio-templates`)
* live in that one directory. So if the origin the window is about to load
* has no data yet, we copy the richest orphaned origin's directory into it.
*
* This is a best-effort, one-time operation:
* - It only copies when the target origin is empty, so it never clobbers
* data the user created on the current origin.
* - A marker file makes it run at most once, so deleting all history won't
* resurrect old data on the next launch.
* - It must run BEFORE the window loads the URL, while the target leveldb is
* still unlocked. The Next.js server process does not touch IndexedDB
* (that is renderer/Chromium state), so running after startNextServer is
* fine as long as it is before createWindow.
*/
export function recoverOrphanedHistory(serverUrl: string): void {
try {
const userData = app.getPath("userData")
const marker = path.join(userData, ".idb-history-recovered")
if (fs.existsSync(marker)) return
const idbDir = path.join(userData, "IndexedDB")
if (!fs.existsSync(idbDir)) {
writeMarker(marker)
return
}
const { hostname, port } = new URL(serverUrl)
const targetName = `http_${hostname}_${port}.indexeddb.leveldb`
const targetPath = path.join(idbDir, targetName)
// If the active origin already has data, leave it untouched.
if (fs.existsSync(targetPath) && idbDataSize(targetPath) > 0) {
writeMarker(marker)
return
}
// Find the richest orphaned origin directory (excluding the target).
const candidates = fs
.readdirSync(idbDir)
.filter(
(name) =>
name.endsWith(".indexeddb.leveldb") && name !== targetName,
)
.map((name) => {
const dir = path.join(idbDir, name)
return { name, dir, size: idbDataSize(dir) }
})
.filter((c) => c.size > 0)
.sort((a, b) => b.size - a.size)
if (candidates.length === 0) {
writeMarker(marker)
return
}
const best = candidates[0]
// Copy the whole leveldb directory (CURRENT/MANIFEST/*.ldb/*.log)
// into the active origin's directory name. The source origin is never
// opened by this app and old versions aren't running (single-instance
// lock), so it is quiescent and safe to copy.
fs.cpSync(best.dir, targetPath, { recursive: true })
console.log(
`Recovered chat history: copied ${best.name} -> ${targetName} (${best.size} bytes)`,
)
writeMarker(marker)
} catch (error) {
// Never block startup on recovery failure.
console.error("History recovery failed:", error)
}
}
/**
* Size of actual IndexedDB data in a leveldb directory.
*
* Counts only data-bearing files: `*.ldb` (compacted tables) and numbered
* write-ahead logs like `000003.log`. Deliberately ignores leveldb's
* human-readable `LOG`/`LOG.old` info files, which exist even for an empty
* database and would otherwise make an empty origin look non-empty.
*/
function idbDataSize(dir: string): number {
let total = 0
try {
if (!fs.statSync(dir).isDirectory()) return 0
for (const file of fs.readdirSync(dir)) {
if (/\.ldb$/i.test(file) || /^\d+\.log$/i.test(file)) {
total += fs.statSync(path.join(dir, file)).size
}
}
} catch {
return 0
}
return total
}
function writeMarker(marker: string): void {
try {
fs.writeFileSync(marker, new Date().toISOString())
} catch {
// Ignore — worst case we re-check on next launch (the empty-target
// guard still prevents clobbering existing data).
}
}
+8
View File
@@ -2,6 +2,7 @@ import { app, BrowserWindow, dialog, shell } from "electron"
import { buildAppMenu } from "./app-menu"
import { getCurrentPresetEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader"
import { recoverOrphanedHistory } from "./history-recovery"
import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server"
import { applyProxyToEnv } from "./proxy-manager"
@@ -56,6 +57,13 @@ if (!gotTheLock) {
serverUrl = await startNextServer()
}
// Recover chat history orphaned by an earlier origin change
// (host/port). Must run before the window loads the URL, while the
// target IndexedDB directory is still unlocked. (#875)
if (!isDev) {
recoverOrphanedHistory(serverUrl)
}
// Create main window
createWindow(serverUrl)
} catch (error) {