Added Subresource Integrity checks from Tachyon

64e23d4fca
This commit is contained in:
the-djmaze 2026-11-01 22:10:41 +01:00
parent 30469ae2dc
commit 124a9c687e
7 changed files with 81 additions and 9 deletions

View file

@ -16,12 +16,14 @@ const
toggle(div);
},
loadScript = src => src ? new Promise((resolve, reject) => {
loadScript = (src, integrity) => src ? new Promise((resolve, reject) => {
const script = doc.createElement('script');
script.onload = () => resolve();
script.onerror = () => reject('Failed loading ' + src);
script.src = src;
// script.async = true;
// script.crossorigin = ''; // same as 'anonymous'
if (integrity) { script.integrity = integrity; }
doc.head.append(script);
}) : Promise.reject('src is empty');
@ -146,8 +148,8 @@ if (!navigator.cookieEnabled) {
RL_APP_DATA = appData;
const url = appData.StaticLibsJs,
cb = () => rl.app.bootstart();
loadScript(url)
.then(() => loadScript(url.replace('/libs.', `/${admin?'admin':'app'}.`)))
loadScript(url, appData.StaticLibsJsSri)
.then(() => loadScript(url.replace('/libs.', `/${admin?'admin':'app'}.`), appData.StaticAppJsSri))
.then(() => appData.PluginsLink ? loadScript(qUri(appData.PluginsLink)) : Promise.resolve())
.then(() => rl.app
? cb()

View file

@ -5,17 +5,19 @@ const { cleanStatic } = require('./tasks/common');
const { js, jsLint } = require('./tasks/js');
const { css, cssLint } = require('./tasks/css');
const { vendors } = require('./tasks/vendors');
const { sri } = require('./tasks/sri');
const clean = gulp.series(cleanStatic);
const lint = gulp.parallel(jsLint, cssLint);
const buildState1 = gulp.parallel(js, css, vendors);
const buildState2 = gulp.series(clean, buildState1);
const buildState2 = gulp.series(clean, buildState1, sri);
const build = gulp.parallel(lint, buildState2);
exports.css = css;
exports.lint = lint;
exports.sri = sri;
exports.build = build;
exports.default = build;

View file

@ -779,6 +779,12 @@ class Actions
$aResult['StaticLibsJs'] = Utils::WebStaticPath('js/' . ($bAppJsDebug ? '' : 'min/') .
'libs' . ($bAppJsDebug ? '' : '.min') . '.js');
if (!$bAppJsDebug) {
$aSri = \SnappyMail\Integrity::loadSriHashes();
$aResult['StaticLibsJsSri'] = $aSri['libs.min.js'] ?? '';
$aResult['StaticAppJsSri'] = $aSri[($bAdmin ? 'admin' : 'app') . '.min.js'] ?? '';
}
$this->oPlugins->InitAppData($bAdmin, $aResult, $oAccount);
return $aResult;

View file

@ -207,9 +207,12 @@ abstract class Service
if ($sResult) {
$sResult .= '<!--cached-->';
} else {
$aSri = $sAppCssMin ? \SnappyMail\Integrity::loadSriHashes() : [];
$sCssFile = ($bAdmin ? 'admin' : 'app').$sAppCssMin.'.css';
$aTemplateParameters['{{BaseAppBootCss}}'] = \file_get_contents(APP_VERSION_ROOT_PATH.'static/css/boot'.$sAppCssMin.'.css');
$aTemplateParameters['{{BaseAppBootScript}}'] = \file_get_contents(APP_VERSION_ROOT_PATH.'static/js'.($sAppJsMin ? '/min' : '').'/boot'.$sAppJsMin.'.js');
$aTemplateParameters['{{BaseAppMainCssLink}}'] = Utils::WebStaticPath('css/'.($bAdmin ? 'admin' : 'app').$sAppCssMin.'.css');
$aTemplateParameters['{{BaseAppMainCssLink}}'] = Utils::WebStaticPath('css/'.$sCssFile);
$aTemplateParameters['{{BaseAppMainCssSri}}'] = empty($aSri[$sCssFile]) ? '' : ' integrity="'.$aSri[$sCssFile].'"';
$aTemplateParameters['{{BaseAppThemeCss}}'] = \preg_replace('/\\s*([:;{},]+)\\s*/s', '$1', $oActions->compileCss($sThemeName, $bAdmin));
$aTemplateParameters['{{BaseLanguage}}'] = $oActions->compileLanguage($sLanguage, $bAdmin);
$aTemplateParameters['{{BaseTemplates}}'] = Utils::ClearHtmlOutput($oServiceActions->compileTemplates($bAdmin));

View file

@ -8,7 +8,7 @@ abstract class Integrity
/**
* Called by https://webmail.tld/?/Test
*/
public static function test()
public static function test(): void
{
$result = static::phpVersion();
if ($result) {
@ -89,14 +89,15 @@ abstract class Integrity
}
}
public static function phpVersion()
public static function phpVersion(): ?string
{
if (PHP_VERSION_ID < 80200) {
return 'Your PHP version ('.PHP_VERSION.') is lower than the minimal required 8.2.0!';
}
return null;
}
public static function phpExtensions()
public static function phpExtensions(): array
{
$aRequirements = array(
'openssl' => extension_loaded('openssl'),
@ -123,4 +124,23 @@ abstract class Integrity
return $aMissing;
}
// https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity
private static $aSri;
public static function loadSriHashes(): array
{
if (!\is_array($aSri)) {
$sSriPath = APP_VERSION_ROOT_PATH . 'static/sri.json';
$aSri = \is_readable($sSriPath) ? \json_decode(\file_get_contents($sSriPath), true) : [];
if (!$aSri) {
$aSri = [
'libs.min.js' => \hash_file('sha384', APP_VERSION_ROOT_PATH . 'static/js/min/libs.min.js'),
'admin.min.js' => \hash_file('sha384', APP_VERSION_ROOT_PATH . 'static/js/min/admin.min.js'),
'app.min.js' => \hash_file('sha384', APP_VERSION_ROOT_PATH . 'static/js/min/app.min.js'),
'admin.min.css' => \hash_file('sha384', APP_VERSION_ROOT_PATH . 'static/css/admin.min.css'),
'app.min.css' => \hash_file('sha384', APP_VERSION_ROOT_PATH . 'static/css/app.min.css'),
];
}
}
return $aSri;
}
}

View file

@ -9,7 +9,7 @@
<meta name="theme-color" content="black">
<title></title>
<style id="app-boot-css">{{BaseAppBootCss}}</style>
<link type="text/css" rel="stylesheet" href="{{BaseAppMainCssLink}}">
<link type="text/css" rel="stylesheet" href="{{BaseAppMainCssLink}}{{BaseAppMainCssSri}}">
<link rel="manifest" href="{{BaseAppManifestLink}}">
<link rel="icon" href="{{BaseFavIconSvg}}" type="image/svg+xml">
<style id="app-theme-style" data-name="{{BaseAppThemeName}}">{{BaseAppThemeCss}}</style>

39
tasks/sri.js Normal file
View file

@ -0,0 +1,39 @@
/* Tachyon Webmail (c) Tachyon | Licensed under AGPL v3 */
const fs = require('fs');
const crypto = require('crypto');
const path = require('path');
const { config } = require('./config');
function sriHash(filePath) {
const data = fs.readFileSync(filePath);
const hash = crypto.createHash('sha384').update(data).digest('base64');
return 'sha384-' + hash;
}
function sri(done) {
const jsMinDir = config.paths.staticMinJS;
const cssDir = config.paths.staticCSS;
const files = [
['libs.min.js', jsMinDir + 'libs.min.js'],
['app.min.js', jsMinDir + 'app.min.js'],
['admin.min.js', jsMinDir + 'admin.min.js'],
['app.min.css', cssDir + 'app.min.css'],
['admin.min.css',cssDir + 'admin.min.css'],
];
const hashes = {};
for (const [key, filePath] of files) {
if (fs.existsSync(filePath)) {
hashes[key] = sriHash(filePath);
}
}
const outPath = path.join('snappymail/v/' + config.devVersion, 'static/sri.json');
fs.writeFileSync(outPath, JSON.stringify(hashes, null, '\t') + '\n');
console.log('[sri] wrote ' + outPath);
done();
}
exports.sri = sri;