fix: trust private diagnostics proxies

This commit is contained in:
Franz Rolfsvaag 2026-07-18 17:04:35 +02:00
parent 5c17408221
commit 328d06662f
16 changed files with 164 additions and 33 deletions

View File

@ -1,5 +1,10 @@
# Lumi changelog # Lumi changelog
## 0.2.5
- Fixed diagnostics behind local/private reverse proxies by accepting forwarded HTTPS only from loopback, RFC1918/link-local IPv4, or private/link-local IPv6 proxy addresses.
- Updated the local diagnostics client to read either JSON or env-style `.secrets` files, including `LUMI_PROD_URL` and `LUMI_DIAG_KEY` aliases.
## 0.2.4 ## 0.2.4
- Changed the production diagnostics endpoint, cURL example, and local-client configuration example to use the actual URL from which the administrator opened Lumi, including trusted reverse-proxy HTTPS handling. - Changed the production diagnostics endpoint, cURL example, and local-client configuration example to use the actual URL from which the administrator opened Lumi, including trusted reverse-proxy HTTPS handling.

View File

@ -677,6 +677,7 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
## Done ## Done
- 2026-07-18: Fixed private reverse-proxy HTTPS recognition for production diagnostics in core 0.2.5 without globally trusting client forwarding headers, and taught the local client to consume the repository's env-style `.secrets` file and short diagnostic variable names directly.
- 2026-07-18: Updated production diagnostics examples in core 0.2.4 to derive the full endpoint and client base URL from the administrator's current Lumi request instead of showing a placeholder hostname. - 2026-07-18: Updated production diagnostics examples in core 0.2.4 to derive the full endpoint and client base URL from the administrator's current Lumi request instead of showing a placeholder hostname.
- 2026-07-18: Fixed the shared submit-action resolver in core 0.2.3: ordinary buttons now inherit their parent form endpoint unless they explicitly declare `formaction`, restoring timed diagnostics-key creation and preventing async update actions from posting back to the Updates page. - 2026-07-18: Fixed the shared submit-action resolver in core 0.2.3: ordinary buttons now inherit their parent form endpoint unless they explicitly declare `formaction`, restoring timed diagnostics-key creation and preventing async update actions from posting back to the Updates page.
- 2026-07-18: Added production-stage plugin update diagnostics in core 0.2.2: selected plugin source is verified before snapshotting, failures record their exact stage and target in update state, and the affected plugin row displays the server error directly. - 2026-07-18: Added production-stage plugin update diagnostics in core 0.2.2: selected plugin source is verified before snapshotting, failures record their exact stage and target in update state, and the affected plugin row displays the server error directly.

View File

@ -43,7 +43,7 @@ The bundled client then runs an allowlisted check and prints its redacted JSON r
npm run diagnostics:production -- update_state npm run diagnostics:production -- update_state
``` ```
`LUMI_DIAGNOSTICS_CONFIG` can point to a key file outside the repository. `LUMI_DIAGNOSTICS_URL` and `LUMI_DIAGNOSTICS_KEY` are also supported for ephemeral environments. `LUMI_DIAGNOSTICS_CONFIG` can point to a key file outside the repository. The client also reads an env-style `.secrets` file and accepts the shorter `LUMI_PROD_URL` / `LUMI_DIAG_KEY` names in addition to `LUMI_DIAGNOSTICS_URL` / `LUMI_DIAGNOSTICS_KEY`.
The supported checks are: The supported checks are:

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.2.4 Version: 0.2.5
## Routes ## Routes
- GET /api/events - GET /api/events
- POST /api/destructive-confirmations - POST /api/destructive-confirmations

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.4", "version": "0.2.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.4", "version": "0.2.5",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.4", "version": "0.2.5",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {

View File

@ -2,6 +2,36 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.2.5",
"ref": "refs/tags/v0.2.5",
"released_at": "2026-07-18",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Recognizes secured diagnostics forwarded through local/private reverse proxies and supports env-style local diagnostic secrets.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.2",
"moderation": "0.1.5",
"okf": "0.1.1",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{ {
"version": "0.2.4", "version": "0.2.4",
"ref": "refs/tags/v0.2.4", "ref": "refs/tags/v0.2.4",

View File

@ -4,7 +4,7 @@ const path = require("path");
const AdmZip = require("adm-zip"); const AdmZip = require("adm-zip");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.4-repair.zip"); const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.5-repair.zip");
const files = [ const files = [
"CHANGELOG.md", "CHANGELOG.md",
"README.md", "README.md",
@ -53,10 +53,10 @@ for (const relativePath of files) {
const manifest = { const manifest = {
schema_version: 1, schema_version: 1,
name: "Lumi core 0.2.4 repair", name: "Lumi core 0.2.5 repair",
target: "core", target: "core",
from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4"], from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.5"],
to_version: "0.2.4", to_version: "0.2.5",
data_policy: "preserve", data_policy: "preserve",
dependency_policy: "sync_on_restart", dependency_policy: "sync_on_restart",
created_at: new Date().toISOString(), created_at: new Date().toISOString(),

View File

@ -9,19 +9,25 @@ if (!allowedChecks.has(check)) {
process.exit(2); process.exit(2);
} }
const configPath = path.resolve(process.env.LUMI_DIAGNOSTICS_CONFIG || path.join(root, ".secrets", "production-diagnostics.json")); const defaultSecretPath = path.join(root, ".secrets");
const configPath = path.resolve(process.env.LUMI_DIAGNOSTICS_CONFIG || (
fs.statSync(defaultSecretPath, { throwIfNoEntry: false })?.isFile()
? defaultSecretPath
: path.join(defaultSecretPath, "production-diagnostics.json")
));
let fileConfig = {}; let fileConfig = {};
try { try {
fileConfig = JSON.parse(fs.readFileSync(configPath, "utf8")); fileConfig = parseConfig(fs.readFileSync(configPath, "utf8"));
} catch (error) { } catch (error) {
if (!process.env.LUMI_DIAGNOSTICS_URL || !process.env.LUMI_DIAGNOSTICS_KEY) { if (!(process.env.LUMI_DIAGNOSTICS_URL || process.env.LUMI_PROD_URL) ||
!(process.env.LUMI_DIAGNOSTICS_KEY || process.env.LUMI_DIAG_KEY)) {
console.error(`Diagnostics configuration was not found or is invalid: ${configPath}`); console.error(`Diagnostics configuration was not found or is invalid: ${configPath}`);
process.exit(2); process.exit(2);
} }
} }
const baseUrl = String(process.env.LUMI_DIAGNOSTICS_URL || fileConfig.base_url || "").trim(); const baseUrl = String(process.env.LUMI_DIAGNOSTICS_URL || process.env.LUMI_PROD_URL || fileConfig.base_url || "").trim();
const key = String(process.env.LUMI_DIAGNOSTICS_KEY || fileConfig.key || "").trim(); const key = String(process.env.LUMI_DIAGNOSTICS_KEY || process.env.LUMI_DIAG_KEY || fileConfig.key || "").trim();
let endpoint; let endpoint;
try { try {
const parsed = new URL(baseUrl); const parsed = new URL(baseUrl);
@ -64,3 +70,29 @@ fetch(endpoint, {
process.exitCode = 1; process.exitCode = 1;
}) })
.finally(() => clearTimeout(timeout)); .finally(() => clearTimeout(timeout));
function parseConfig(raw) {
const source = String(raw || "").trim();
try {
const parsed = JSON.parse(source);
return {
base_url: parsed.base_url || parsed.url || "",
key: parsed.key || ""
};
} catch {
const values = {};
for (const line of source.split(/\r?\n/)) {
const match = line.trim().match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
values[match[1]] = value;
}
return {
base_url: values.LUMI_DIAGNOSTICS_URL || values.LUMI_PROD_URL || "",
key: values.LUMI_DIAGNOSTICS_KEY || values.LUMI_DIAG_KEY || ""
};
}
}

View File

@ -7,7 +7,7 @@ const AdmZip = require("adm-zip");
const { verifyPatchPackage } = require("../src/services/update-manager"); const { verifyPatchPackage } = require("../src/services/update-manager");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.4-repair.zip"); const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.5-repair.zip");
assert.equal(fs.existsSync(archivePath), true, "build the repair patch first"); assert.equal(fs.existsSync(archivePath), true, "build the repair patch first");
const zip = new AdmZip(archivePath); const zip = new AdmZip(archivePath);
const entries = zip.getEntries().filter((entry) => !entry.isDirectory); const entries = zip.getEntries().filter((entry) => !entry.isDirectory);
@ -15,9 +15,9 @@ const names = new Set(entries.map((entry) => entry.entryName.replace(/\\/g, "/")
assert.equal(names.has("patch-manifest.json"), true); assert.equal(names.has("patch-manifest.json"), true);
const manifest = JSON.parse(zip.readAsText("patch-manifest.json")); const manifest = JSON.parse(zip.readAsText("patch-manifest.json"));
assert.equal(manifest.target, "core"); assert.equal(manifest.target, "core");
assert.equal(manifest.to_version, "0.2.4"); assert.equal(manifest.to_version, "0.2.5");
assert.equal(manifest.data_policy, "preserve"); assert.equal(manifest.data_policy, "preserve");
assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4"]); assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.5"]);
const forbidden = /^(?:data|plugins|node_modules|config|storage|uploads|logs|database|databases|knowledge\/(?:community|corrections))(?:\/|$)|^\.env(?:\.|$)|^\.secrets$/; const forbidden = /^(?:data|plugins|node_modules|config|storage|uploads|logs|database|databases|knowledge\/(?:community|corrections))(?:\/|$)|^\.env(?:\.|$)|^\.secrets$/;
for (const entry of entries) { for (const entry of entries) {
@ -29,7 +29,7 @@ for (const [relativePath, expected] of Object.entries(manifest.files)) {
assert.equal(actual, expected, `${relativePath} checksum`); assert.equal(actual, expected, `${relativePath} checksum`);
} }
assert.equal(Object.keys(manifest.files).length + 1, entries.length, "every repair file must be checksummed"); assert.equal(Object.keys(manifest.files).length + 1, entries.length, "every repair file must be checksummed");
assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.4"); assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.5");
const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-")); const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-"));
try { try {
@ -47,11 +47,11 @@ try {
fs.writeFileSync(target, `preserve:${sentinel}`); fs.writeFileSync(target, `preserve:${sentinel}`);
} }
zip.extractAllTo(simulation, true); zip.extractAllTo(simulation, true);
assert.equal(verifyPatchPackage(simulation).to_version, "0.2.4"); assert.equal(verifyPatchPackage(simulation).to_version, "0.2.5");
for (const sentinel of sentinels) { for (const sentinel of sentinels) {
assert.equal(fs.readFileSync(path.join(simulation, sentinel), "utf8"), `preserve:${sentinel}`); assert.equal(fs.readFileSync(path.join(simulation, sentinel), "utf8"), `preserve:${sentinel}`);
} }
assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.4"); assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.5");
assert.equal(fs.existsSync(path.join(simulation, "src", "services", "dependency-manager.js")), true); assert.equal(fs.existsSync(path.join(simulation, "src", "services", "dependency-manager.js")), true);
assert.equal(fs.existsSync(path.join(simulation, "src", "services", "production-diagnostics.js")), true); assert.equal(fs.existsSync(path.join(simulation, "src", "services", "production-diagnostics.js")), true);
assert.equal(fs.existsSync(path.join(simulation, "src", "web", "views", "admin-diagnostics.ejs")), true); assert.equal(fs.existsSync(path.join(simulation, "src", "web", "views", "admin-diagnostics.ejs")), true);

View File

@ -23,6 +23,18 @@ assert.equal(diagnostics.verifyAccessKey("wrong-prefix", hash), false);
assert.equal(diagnostics.isSecureDiagnosticRequest({ secure: true, ip: "203.0.113.10" }), true); assert.equal(diagnostics.isSecureDiagnosticRequest({ secure: true, ip: "203.0.113.10" }), true);
assert.equal(diagnostics.isSecureDiagnosticRequest({ secure: false, ip: "127.0.0.1" }), true); assert.equal(diagnostics.isSecureDiagnosticRequest({ secure: false, ip: "127.0.0.1" }), true);
assert.equal(diagnostics.isSecureDiagnosticRequest({ secure: false, ip: "203.0.113.10" }), false); assert.equal(diagnostics.isSecureDiagnosticRequest({ secure: false, ip: "203.0.113.10" }), false);
const proxiedRequest = {
secure: false,
protocol: "http",
socket: { remoteAddress: "::ffff:172.19.0.4" },
get(name) { return name === "x-forwarded-proto" ? "https" : ""; }
};
assert.equal(diagnostics.isTrustedPrivateProxyAddress("172.19.0.4"), true);
assert.equal(diagnostics.isTrustedPrivateProxyAddress("192.168.1.10"), true);
assert.equal(diagnostics.isTrustedPrivateProxyAddress("203.0.113.10"), false);
assert.equal(diagnostics.diagnosticsRequestProtocol(proxiedRequest), "https");
assert.equal(diagnostics.isSecureDiagnosticRequest(proxiedRequest), true);
assert.equal(diagnostics.diagnosticsRequestProtocol({ ...proxiedRequest, socket: { remoteAddress: "203.0.113.10" } }), "http");
const redacted = diagnostics.redactDiagnosticValue({ const redacted = diagnostics.redactDiagnosticValue({
token: "top-secret", token: "top-secret",
@ -82,12 +94,16 @@ assert.match(viewSource, /\.secrets/);
assert.match(viewSource, /diagnosticsEndpointUrl/); assert.match(viewSource, /diagnosticsEndpointUrl/);
assert.match(viewSource, /diagnosticsBaseUrl/); assert.match(viewSource, /diagnosticsBaseUrl/);
assert.doesNotMatch(viewSource, /https:\/\/your-lumi-host/); assert.doesNotMatch(viewSource, /https:\/\/your-lumi-host/);
assert.match(serverSource, /const diagnosticsBaseUrl = `\$\{req\.protocol\}:\/\/\$\{req\.get\("host"\)\}`/); assert.match(serverSource, /const diagnosticsBaseUrl = `\$\{diagnosticsRequestProtocol\(req\)\}:\/\/\$\{req\.get\("host"\)\}`/);
const clientSource = fs.readFileSync(path.join(root, "scripts", "production-diagnostics-client.js"), "utf8"); const clientSource = fs.readFileSync(path.join(root, "scripts", "production-diagnostics-client.js"), "utf8");
assert.match(clientSource, /\.secrets["'], "production-diagnostics\.json"/); assert.match(clientSource, /path\.join\(root, "\.secrets"\)/);
assert.match(clientSource, /path\.join\(defaultSecretPath, "production-diagnostics\.json"\)/);
assert.match(clientSource, /method: "POST"/); assert.match(clientSource, /method: "POST"/);
assert.match(clientSource, /new URL\("\/api\/diagnostics\/v1\/run"/); assert.match(clientSource, /new URL\("\/api\/diagnostics\/v1\/run"/);
assert.match(clientSource, /LUMI_PROD_URL/);
assert.match(clientSource, /LUMI_DIAG_KEY/);
assert.match(clientSource, /function parseConfig\(raw\)/);
assert.doesNotMatch(clientSource, /console\.(?:log|error)\(\s*key\s*\)/, "client must not print its access key"); assert.doesNotMatch(clientSource, /console\.(?:log|error)\(\s*key\s*\)/, "client must not print its access key");
console.log("Production diagnostics verification passed: fixed read-only checks, redaction, key validation, rate limiting, HTTPS proxy trust, and admin-only controls."); console.log("Production diagnostics verification passed: fixed read-only checks, redaction, key validation, rate limiting, HTTPS proxy trust, and admin-only controls.");

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning"); const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const releaseVersion = "0.2.4"; const releaseVersion = "0.2.5";
const previousCoreVersion = "0.2.3"; const previousCoreVersion = "0.2.4";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = { const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, changedPlugins.lumi_ai.to); assert.equal(webSearch.minimum_lumi_ai_version, changedPlugins.lumi_ai.to);
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.4 and 11 changed plugin/tool packages."); console.log("Release metadata verification passed: core 0.2.5 and 11 changed plugin/tool packages.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json"); const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.deepEqual(releaseVersions, ["0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) { for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref); assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -35,8 +35,9 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
} }
const baseTarget = { const baseTarget = {
current_version: "0.2.3", current_version: "0.2.4",
available_versions: [ available_versions: [
{ version: "0.2.5", ref: "refs/tags/v0.2.5", rollback_safe: true },
{ version: "0.2.4", ref: "refs/tags/v0.2.4", rollback_safe: true }, { version: "0.2.4", ref: "refs/tags/v0.2.4", rollback_safe: true },
{ version: "0.2.3", ref: "refs/tags/v0.2.3", rollback_safe: true }, { version: "0.2.3", ref: "refs/tags/v0.2.3", rollback_safe: true },
{ version: "0.2.2", ref: "refs/tags/v0.2.2", rollback_safe: true }, { version: "0.2.2", ref: "refs/tags/v0.2.2", rollback_safe: true },
@ -64,7 +65,7 @@ const corrected = buildStatus({
channel: "stable" channel: "stable"
}); });
assert.equal(corrected.version_correction, true); assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.4"); assert.equal(corrected.safe_target_version, "0.2.5");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -272,8 +272,39 @@ function consumeRateLimit(fingerprint, now = Date.now()) {
function isSecureDiagnosticRequest(req) { function isSecureDiagnosticRequest(req) {
if (req.secure === true) return true; if (req.secure === true) return true;
const address = String(req.ip || req.socket?.remoteAddress || ""); const address = String(req.socket?.remoteAddress || req.ip || "");
return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(address); if (isLoopbackAddress(address)) return true;
return diagnosticsRequestProtocol(req) === "https";
}
function diagnosticsRequestProtocol(req) {
if (req.secure === true || String(req.protocol || "").toLowerCase() === "https") return "https";
const forwarded = String(req.get?.("x-forwarded-proto") || "")
.split(",")[0]
.trim()
.toLowerCase();
const proxyAddress = String(req.socket?.remoteAddress || "");
if (forwarded === "https" && isTrustedPrivateProxyAddress(proxyAddress)) return "https";
return "http";
}
function isTrustedPrivateProxyAddress(value) {
const address = String(value || "").trim().toLowerCase().replace(/^::ffff:/, "");
if (isLoopbackAddress(address)) return true;
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(address)) {
const parts = address.split(".").map(Number);
if (parts.some((part) => part < 0 || part > 255)) return false;
return parts[0] === 10 ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254);
}
return address.startsWith("fc") || address.startsWith("fd") || /^fe[89ab]/.test(address);
}
function isLoopbackAddress(value) {
const address = String(value || "").trim().toLowerCase();
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
} }
function summarizeRecovery(marker) { function summarizeRecovery(marker) {
@ -305,9 +336,11 @@ module.exports = {
authenticateDiagnosticsRequest, authenticateDiagnosticsRequest,
consumeRateLimit, consumeRateLimit,
diagnosticsAccessStatus, diagnosticsAccessStatus,
diagnosticsRequestProtocol,
hashAccessKey, hashAccessKey,
issueDiagnosticsAccessKey, issueDiagnosticsAccessKey,
isSecureDiagnosticRequest, isSecureDiagnosticRequest,
isTrustedPrivateProxyAddress,
recentDiagnosticAudit, recentDiagnosticAudit,
redactDiagnosticValue, redactDiagnosticValue,
revokeDiagnosticsAccess, revokeDiagnosticsAccess,

View File

@ -110,6 +110,7 @@ const {
auditDiagnosticRequest, auditDiagnosticRequest,
authenticateDiagnosticsRequest, authenticateDiagnosticsRequest,
diagnosticsAccessStatus, diagnosticsAccessStatus,
diagnosticsRequestProtocol,
issueDiagnosticsAccessKey, issueDiagnosticsAccessKey,
recentDiagnosticAudit, recentDiagnosticAudit,
revokeDiagnosticsAccess, revokeDiagnosticsAccess,
@ -5770,7 +5771,7 @@ function createWebServer({ loadPlugins, discordClient }) {
}); });
const renderDiagnosticsAdmin = (req, res, values = {}) => { const renderDiagnosticsAdmin = (req, res, values = {}) => {
const diagnosticsBaseUrl = `${req.protocol}://${req.get("host")}`; const diagnosticsBaseUrl = `${diagnosticsRequestProtocol(req)}://${req.get("host")}`;
res.set("Cache-Control", "no-store"); res.set("Cache-Control", "no-store");
res.render("admin-diagnostics", { res.render("admin-diagnostics", {
title: "Production diagnostics", title: "Production diagnostics",

View File

@ -1,6 +1,6 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.2.4", "version": "0.2.5",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-18", "released_at": "2026-07-18",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, secured read-only production diagnostics with request-aware examples, and the shared form-action fix required by timed confirmations and repository updates. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, and secrets.", "migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, secured read-only production diagnostics with private-proxy HTTPS support, and the shared form-action fix required by timed confirmations and repository updates. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, and secrets.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -73,6 +73,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Uses the administrator's current Lumi origin in production diagnostics examples; preserved local data is not replaced." "migration_notes": "Uses the administrator's current Lumi origin in production diagnostics examples; preserved local data is not replaced."
},
{
"version": "0.2.5",
"channel": "stable",
"released_at": "2026-07-18",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Recognizes forwarded HTTPS from local/private reverse proxies for secured diagnostics; preserved local data is not replaced."
} }
] ]
} }