Skip to content

Commit 5ca65d6

Browse files
security: implement cryptographic build-time dependency verification and port hardening
- Hardened desktop-app/prepare.js to compute and verify SHA-384 hashes of all downloaded offline assets against SRI hash parameters in index.html. - Added the missing bootstrap.bundle.min.js SRI hash to index.html to ensure end-to-end supply chain integrity. - Hardened neutralino.config.json by changing the WebSocket communication server to a static local port (28915), enabling exitProcessOnClose to prevent orphaned background processes, and turning off logs in production.
1 parent d4d1a2a commit 5ca65d6

4 files changed

Lines changed: 106 additions & 38 deletions

File tree

desktop-app/neutralino.config.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
"applicationId": "com.markdownviewer.desktop",
44
"version": "1.0.0",
55
"defaultMode": "window",
6-
"port": 0,
6+
"port": 28915,
77
"documentRoot": "/resources/",
88
"url": "/",
99
"enableServer": true,
1010
"enableNativeAPI": true,
1111
"tokenSecurity": "one-time",
1212
"logging": {
13-
"enabled": true,
14-
"writeToLogFile": true
13+
"enabled": false,
14+
"writeToLogFile": false
1515
},
1616
"nativeAllowList": [
1717
"app.exit",
@@ -40,7 +40,7 @@
4040
"maximize": false,
4141
"hidden": false,
4242
"resizable": true,
43-
"exitProcessOnClose": false
43+
"exitProcessOnClose": true
4444
},
4545
"browser": {
4646
"globalVariables": {},

desktop-app/prepare.js

Lines changed: 100 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
*
66
* Copies shared browser-version files (script.js, styles.css, assets/)
77
* from the repo root into desktop-app/resources/, downloads all remote CDN
8-
* libraries locally for 100% offline capabilities, and generates a
9-
* Neutralinojs-compatible index.html.
8+
* libraries locally for 100% offline capabilities, validates their cryptographic
9+
* integrity using SRI hashes (SHA-384), and generates a Neutralinojs-compatible index.html.
1010
*/
1111

1212
const fs = require("fs");
1313
const path = require("path");
1414
const https = require("https");
15+
const crypto = require("crypto");
1516

1617
const ROOT_DIR = path.resolve(__dirname, "..");
1718
const RESOURCES_DIR = path.resolve(__dirname, "resources");
@@ -45,43 +46,105 @@ console.log("✓ Copied styles.css → resources/styles.css");
4546
copyDirSync(path.join(ROOT_DIR, "assets"), path.join(RESOURCES_DIR, "assets"));
4647
console.log("✓ Copied assets/ → resources/assets/");
4748

48-
// Download helper
49-
function downloadFile(url, destPath) {
49+
/**
50+
* Validates the cryptographic integrity of a file against an expected SHA-384 hash.
51+
*/
52+
function verifyIntegrity(filePath, expectedSha384) {
5053
return new Promise((resolve, reject) => {
51-
if (fs.existsSync(destPath) && fs.statSync(destPath).size > 0) {
52-
resolve();
54+
if (!expectedSha384) {
55+
resolve(true); // Skip validation if no hash is provided (e.g., relative fonts)
5356
return;
5457
}
55-
console.log(`Downloading offline dependency: ${path.basename(destPath)}...`);
56-
https.get(url, (res) => {
57-
if (res.statusCode !== 200) {
58-
reject(new Error(`Failed to load ${url} (${res.statusCode})`));
59-
return;
58+
59+
const hash = crypto.createHash("sha384");
60+
const stream = fs.createReadStream(filePath);
61+
62+
stream.on("data", data => hash.update(data));
63+
stream.on("end", () => {
64+
const calculated = "sha384-" + hash.digest("base64");
65+
if (calculated === expectedSha384) {
66+
resolve(true);
67+
} else {
68+
reject(new Error(`Integrity mismatch for ${path.basename(filePath)}:\nExpected: ${expectedSha384}\nCalculated: ${calculated}`));
6069
}
61-
const stream = fs.createWriteStream(destPath);
62-
res.pipe(stream);
63-
stream.on("finish", () => {
64-
stream.close();
65-
resolve();
66-
});
67-
}).on("error", reject);
70+
});
71+
stream.on("error", reject);
72+
});
73+
}
74+
75+
/**
76+
* Downloads a file from a URL and verifies its integrity.
77+
*/
78+
function downloadFile(url, destPath, expectedSha384) {
79+
return new Promise((resolve, reject) => {
80+
// If file already exists, verify its integrity before skipping
81+
if (fs.existsSync(destPath) && fs.statSync(destPath).size > 0) {
82+
verifyIntegrity(destPath, expectedSha384)
83+
.then(() => resolve())
84+
.catch(() => {
85+
console.log(`↻ Cached file ${path.basename(destPath)} failed integrity check. Re-downloading...`);
86+
fs.unlinkSync(destPath);
87+
downloadAndVerify();
88+
});
89+
return;
90+
}
91+
92+
downloadAndVerify();
93+
94+
function downloadAndVerify() {
95+
console.log(`Downloading offline dependency: ${path.basename(destPath)}...`);
96+
https.get(url, (res) => {
97+
if (res.statusCode !== 200) {
98+
reject(new Error(`Failed to load ${url} (${res.statusCode})`));
99+
return;
100+
}
101+
const stream = fs.createWriteStream(destPath);
102+
res.pipe(stream);
103+
stream.on("finish", () => {
104+
stream.close();
105+
106+
// Verify integrity of downloaded file
107+
verifyIntegrity(destPath, expectedSha384)
108+
.then(() => resolve())
109+
.catch(err => {
110+
// Delete corrupted file
111+
if (fs.existsSync(destPath)) {
112+
fs.unlinkSync(destPath);
113+
}
114+
reject(err);
115+
});
116+
});
117+
}).on("error", reject);
118+
}
68119
});
69120
}
70121

71122
async function prepareOfflineDependencies() {
72-
console.log("\nStarting Offline Assets Preparation...");
123+
console.log("\nStarting Secure Offline Assets Preparation...");
73124
let html = fs.readFileSync(path.join(ROOT_DIR, "index.html"), "utf-8");
74125

75-
// Find all CDN script and link tags
76-
const cdnRegex = /(href|src)="(https:\/\/(?:cdnjs\.cloudflare\.com|cdn\.jsdelivr\.net)\/[^"]+)"/g;
126+
// Find all CDN script and link tags that match standard script/stylesheet declarations
127+
const tagRegex = /<(link|script)[^>]+(?:href|src)="https:\/\/(?:cdnjs\.cloudflare\.com|cdn\.jsdelivr\.net)\/[^"]+"[^>]*>/g;
77128
let match;
78129
const downloads = [];
79130
const replacements = [];
80131

81-
while ((match = cdnRegex.exec(html)) !== null) {
82-
const attr = match[1];
83-
const url = match[2];
132+
while ((match = tagRegex.exec(html)) !== null) {
133+
const fullTag = match[0];
84134

135+
// Extract url
136+
const urlMatch = /(?:href|src)="([^"]+)"/.exec(fullTag);
137+
if (!urlMatch) continue;
138+
const url = urlMatch[1];
139+
140+
// Extract integrity hash
141+
const integrityMatch = /integrity="([^"]+)"/.exec(fullTag);
142+
const expectedSha384 = integrityMatch ? integrityMatch[1] : null;
143+
144+
if (!expectedSha384) {
145+
console.warn(`⚠ Warning: CDN dependency is missing an integrity hash: ${url}`);
146+
}
147+
85148
// Determine local filename - sanitize package version tags or query strings
86149
const urlPath = new URL(url).pathname;
87150
let filename = path.basename(urlPath);
@@ -90,27 +153,29 @@ async function prepareOfflineDependencies() {
90153
}
91154

92155
const localDest = path.join(LIBS_DIR, filename);
93-
downloads.push(downloadFile(url, localDest));
156+
downloads.push(downloadFile(url, localDest, expectedSha384));
94157

95158
// Queue replacement in HTML to point to local libs folder
159+
const attr = fullTag.includes("href=") ? "href" : "src";
96160
replacements.push({
97161
original: `${attr}="${url}"`,
98162
replaced: `${attr}="/libs/${filename}"`
99163
});
100164
}
101165

102-
// Also download the relative fonts loaded by bootstrap-icons
166+
// Also download the relative fonts loaded by bootstrap-icons (these are loaded by the stylesheet and do not have SRI tags)
103167
const fontDir = path.join(LIBS_DIR, "fonts");
104168
fs.mkdirSync(fontDir, { recursive: true });
105-
downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2", path.join(fontDir, "bootstrap-icons.woff2")));
106-
downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff", path.join(fontDir, "bootstrap-icons.woff")));
169+
downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2", path.join(fontDir, "bootstrap-icons.woff2"), null));
170+
downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff", path.join(fontDir, "bootstrap-icons.woff"), null));
107171

108-
// Wait for all downloads to finish
172+
// Wait for all downloads and cryptographic validations to finish
109173
try {
110174
await Promise.all(downloads);
111-
console.log("✓ All offline libraries successfully prepared.");
175+
console.log("✓ All offline libraries successfully downloaded and cryptographically validated.");
112176
} catch (err) {
113-
console.warn("⚠ Failed to bundle some dependencies offline. Fallback to CDNs will occur.", err.message);
177+
console.error("✗ Critical Security Error: Dependency integrity check failed!", err.message);
178+
process.exit(1); // Abort execution if a download fails validation
114179
}
115180

116181
// Apply replacements in HTML
@@ -142,4 +207,7 @@ async function prepareOfflineDependencies() {
142207
console.log("\nDone! Run `npm run dev` to start the desktop app.");
143208
}
144209

145-
prepareOfflineDependencies().catch(console.error);
210+
prepareOfflineDependencies().catch(err => {
211+
console.error("✗ Fatal Prepare Error:", err);
212+
process.exit(1);
213+
});

desktop-app/resources/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -909,7 +909,7 @@ <h3 class="modal-section-title">Open-source credits</h3>
909909
</div>
910910
</div>
911911

912-
<script src="/libs/bootstrap.bundle.min.js"></script>
912+
<script src="/libs/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
913913
<script src="/js/neutralino.js"></script>
914914
<script src="/js/main.js"></script>
915915
<script src="/js/script.js"></script>

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,7 @@ <h3 class="modal-section-title">Open-source credits</h3>
906906
</div>
907907
</div>
908908

909-
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js"></script>
909+
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
910910
<script src="script.js"></script>
911911
</body>
912912
</html>

0 commit comments

Comments
 (0)