Skip to content

Commit 609f5d0

Browse files
CopilotPDowney
andauthored
Security and flexibility improvements for external-services.js
Agent-Logs-Url: https://github.com/EngineScript/EngineScript/sessions/4a53353e-ab9c-4ee8-97bf-bc42bbd4e33b Co-authored-by: PDowney <[email protected]>
1 parent d49cd2b commit 609f5d0

2 files changed

Lines changed: 59 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ Changes are organized by date, with the most recent changes listed first.
66

77
## 2026-04-11
88

9+
### 🔒 EXTERNAL SERVICES SECURITY & FLEXIBILITY IMPROVEMENTS
10+
11+
- Added response origin validation in `fetchAvailableServices`: after a successful HTTP response, the response URL origin is now compared against `window.location.origin` and an error is thrown if they do not match, preventing credential exposure via redirected or compromised API endpoints.
12+
- Moved service preferences from cookie storage to `localStorage` in `handleSavePreferences` and `loadServicePreferences`, eliminating the risk of tampered cookie values being injected by a malicious user.
13+
- Enhanced `buildFaIconClass` to detect and honour explicit FontAwesome style prefixes (e.g. `fab`, `far`, `fa-brands`) embedded in the icon input string, removing the previous hardcoded `fas`-only restriction.
14+
- Consolidated icon construction in `updateServiceCardStatus` to use `this.buildFaIconClass` instead of inline sanitization, ensuring all icon class building goes through the same validated path.
15+
- Changed `credentials: 'include'` to `credentials: 'same-origin'` in `updateFeedServiceStatus` to prevent credentials from being sent to potentially untrusted or user-configurable feed URLs.
16+
917
### 🔧 VHOST IMPORT BUG FIXES & IMPROVEMENTS
1018

1119
- Updated the single-zip database file detection in `scripts/functions/vhost/vhost-import.sh` to search for both `*.sql` and `*.sql.gz` patterns, so compressed database dumps are correctly found and imported instead of failing silently.

config/var/www/admin/control-panel/external-services/external-services.js

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,12 @@ export class ExternalServicesManager {
411411
if (!response.ok) {
412412
throw new Error(`HTTP ${response.status}`);
413413
}
414+
415+
const expectedOrigin = window.location.origin;
416+
const responseOrigin = new URL(response.url, window.location.href).origin;
417+
if (responseOrigin !== expectedOrigin) {
418+
throw new Error(`Unexpected response origin: ${responseOrigin}`);
419+
}
414420

415421
const data = await response.json();
416422
let services = data.services || data;
@@ -755,8 +761,8 @@ export class ExternalServicesManager {
755761
const currentPreferences = this.getAllowedPreferenceChanges(storedPreferences);
756762
this.applyPreferenceChanges(currentPreferences, safeChanges);
757763

758-
// Save to cookie
759-
writeCookie('servicePreferences', encodeURIComponent(JSON.stringify(currentPreferences)), 365);
764+
// Save preferences to local storage (avoid tamper-prone cookie storage)
765+
window.localStorage.setItem('servicePreferences', JSON.stringify(currentPreferences));
760766
this.applyPreferenceChanges(services, safeChanges);
761767

762768
// Clear pending changes
@@ -836,22 +842,46 @@ export class ExternalServicesManager {
836842

837843
/**
838844
* Build a canonical, sanitized FontAwesome class string.
839-
* @param {string} iconSuffix - Icon suffix (for example: "spinner" or "fa-spinner fa-spin")
840-
* @param {string|null} fallbackSuffix - Optional fallback icon suffix
845+
* Supports explicit style prefixes in the input (for example: "fab fa-github").
846+
* @param {string} iconSuffix - Icon suffix or class list (for example: "spinner", "fa-spinner fa-spin", "far fa-clock")
847+
* @param {string|null} fallbackSuffix - Optional fallback icon suffix/class list
841848
* @returns {string} Sanitized class string (for example: "fas fa-spinner")
842849
*/
843850
buildFaIconClass(iconSuffix, fallbackSuffix = null) {
844-
let safeSuffix = sanitizeFaIconSuffix(iconSuffix);
851+
const parseIconInput = (value) => {
852+
if (typeof value !== "string" || !value.trim()) {
853+
return { stylePrefix: null, iconName: null };
854+
}
845855

846-
if (!safeSuffix && fallbackSuffix) {
847-
safeSuffix = sanitizeFaIconSuffix(fallbackSuffix);
848-
}
856+
const parts = value.trim().split(/\s+/);
857+
let stylePrefix = null;
858+
let iconName = null;
849859

850-
if (!safeSuffix) {
851-
safeSuffix = DEFAULT_ICON_SUFFIX;
852-
}
860+
for (const part of parts) {
861+
if (/^fa[rsbdl]$/.test(part) || /^fa-(solid|regular|brands|light|duotone)$/.test(part)) {
862+
stylePrefix = part;
863+
continue;
864+
}
865+
866+
if (!iconName && /^fa-[a-z0-9-]+$/.test(part) && !/^fa-(spin|pulse|fw|lg|xs|sm|1x|2x|3x|4x|5x|6x|7x|8x|9x|10x)$/.test(part)) {
867+
iconName = part.replace(/^fa-/, "");
868+
}
869+
}
870+
871+
if (!iconName) {
872+
iconName = sanitizeFaIconSuffix(value);
873+
}
874+
875+
return { stylePrefix, iconName };
876+
};
877+
878+
const primary = parseIconInput(iconSuffix);
879+
const fallback = parseIconInput(fallbackSuffix);
880+
881+
const stylePrefix = primary.stylePrefix || fallback.stylePrefix || "fas";
882+
const safeSuffix = primary.iconName || fallback.iconName || DEFAULT_ICON_SUFFIX;
853883

854-
return `fas fa-${safeSuffix}`;
884+
return sanitizeFaIconClass(`${stylePrefix} fa-${safeSuffix}`);
855885
}
856886

857887
/**
@@ -1006,9 +1036,7 @@ export class ExternalServicesManager {
10061036
statusSpan.textContent = '';
10071037
// Create icon element safely
10081038
const iconElement = document.createElement("i");
1009-
// Use suffix sanitizer here because class is constructed as `fas fa-${suffix}`
1010-
const safeIconSuffix = sanitizeFaIconSuffix(statusIconSuffix) || DEFAULT_ICON_SUFFIX;
1011-
iconElement.className = `fas fa-${safeIconSuffix}`;
1039+
iconElement.className = this.buildFaIconClass(statusIconSuffix);
10121040
statusSpan.appendChild(iconElement);
10131041
const safeStatusText = statusDescription == null ? '' : String(statusDescription);
10141042
statusSpan.appendChild(document.createTextNode(` ${safeStatusText}`));
@@ -1144,7 +1172,7 @@ export class ExternalServicesManager {
11441172

11451173
return fetch(apiUrl, {
11461174
signal: signal,
1147-
credentials: 'include'
1175+
credentials: 'same-origin'
11481176
});
11491177
}, serviceKey);
11501178

@@ -1286,19 +1314,19 @@ export class ExternalServicesManager {
12861314
*/
12871315
loadServicePreferences() {
12881316
try {
1289-
// Try to load from cookie first
1290-
const cookiePrefs = readCookie('servicePreferences');
1291-
if (cookiePrefs) {
1317+
// Try to load from local storage
1318+
const storedPrefs = window.localStorage.getItem('servicePreferences');
1319+
if (storedPrefs) {
12921320
try {
1293-
const parsed = JSON.parse(decodeURIComponent(cookiePrefs));
1321+
const parsed = JSON.parse(storedPrefs);
12941322
// Validate it's an object with expected structure
12951323
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
12961324
return parsed;
12971325
}
12981326
} catch (parseError) {
1299-
console.error('Failed to parse cookie preferences:', parseError);
1300-
// Clear invalid cookie
1301-
removeCookie('servicePreferences');
1327+
console.error('Failed to parse stored preferences:', parseError);
1328+
// Clear invalid entry
1329+
window.localStorage.removeItem('servicePreferences');
13021330
}
13031331
}
13041332

0 commit comments

Comments
 (0)