From 4a07fbce3f02b1f0632c122870a2cc4a185cc6dd Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:15:03 +0200 Subject: [PATCH 1/2] fix(skoda): allow iprenders blob host and drop auth token on render image fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real Skoda render CDN is iprenders.blob.core.windows.net (Azure blob), which the SSRF allowlist correctly rejected — so vehicle images never stored. Pin the exact host and fetch without the Authorization header (live-confirmed 200 without it; sending the Skoda token to a third-party CDN would leak it). --- src/services/skoda/skodaClient.js | 12 +++++++----- tests/skoda_client.test.js | 12 ++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js index 50961782..b0576c46 100644 --- a/src/services/skoda/skodaClient.js +++ b/src/services/skoda/skodaClient.js @@ -8,7 +8,10 @@ class SkodaApiError extends Error { } // Hosts observed serving compositeRenders images; confirm/extend via Task-3 spike. -const RENDER_HOST_ALLOWLIST = [/\.azureedge\.net$/, /\.skoda-auto\.cz$/]; +// Hosts observed serving compositeRenders images (live-confirmed). iprenders is +// Skoda's public Azure blob bucket — pinned to the exact host (not all of +// *.blob.core.windows.net, which any Azure tenant can use). +const RENDER_HOST_ALLOWLIST = [/\.azureedge\.net$/, /\.skoda-auto\.cz$/, /^iprenders\.blob\.core\.windows\.net$/]; class SkodaClient { constructor({ getSession, saveSession, fetchImpl = fetch }) { @@ -52,15 +55,14 @@ class SkodaClient { async renderImage(url) { // The url comes from the Skoda API response — never fetch it unvalidated, // and never send our bearer token to an arbitrary host (SSRF/token leak). - // Confirm/extend the allowlist from the Task-3 live spike; if the spike - // shows the CDN serves images unauthenticated, drop the auth header here. let parsed; try { parsed = new URL(url); } catch { throw new SkodaApiError('invalid render url', 'SKODA_API_ERROR', 0); } if (parsed.protocol !== 'https:' || !RENDER_HOST_ALLOWLIST.some((re) => re.test(parsed.hostname))) { throw new SkodaApiError(`render url host not allowed: ${parsed.hostname}`, 'SKODA_API_ERROR', 0); } - const session = this.getSession(); - const res = await this.fetchImpl(parsed.toString(), { headers: { authorization: `Bearer ${session.accessToken}` } }); + // No auth header: the render is a public blob (live-confirmed 200 without + // it), and sending the Skoda access token to a third-party CDN would leak it. + const res = await this.fetchImpl(parsed.toString()); if (res.status >= 400) throw new SkodaApiError(`image fetch failed ${res.status}`, 'SKODA_API_ERROR', res.status); return Buffer.from(await res.arrayBuffer()); } diff --git a/tests/skoda_client.test.js b/tests/skoda_client.test.js index c0b1e8e5..61ce6c28 100644 --- a/tests/skoda_client.test.js +++ b/tests/skoda_client.test.js @@ -94,9 +94,21 @@ test('renderImage rejects non-allowlisted or non-https hosts', async () => { const { client } = makeClient([]); await assert.rejects(client.renderImage('https://evil.example/x.png'), (e) => e.code === 'SKODA_API_ERROR'); await assert.rejects(client.renderImage('http://ip-modcwp.azureedge.net/x.png'), (e) => e.code === 'SKODA_API_ERROR'); + await assert.rejects(client.renderImage('https://evil.blob.core.windows.net/x.png'), (e) => e.code === 'SKODA_API_ERROR'); await assert.rejects(client.renderImage('nicht-mal-eine-url'), (e) => e.code === 'SKODA_API_ERROR'); }); +test('renderImage fetches the real Skoda blob host WITHOUT sending the auth token', async () => { + const png = new TextEncoder().encode('PNG').buffer; + const { client, calls } = makeClient([ + ['iprenders.blob.core.windows.net', { status: 200, ok: true, headers: new Headers(), arrayBuffer: async () => png }], + ]); + const buf = await client.renderImage('https://iprenders.blob.core.windows.net/renders/car.png'); + assert.ok(Buffer.isBuffer(buf)); + // the Skoda access token must never be sent to the third-party CDN + assert.equal(calls[calls.length - 1].auth, undefined); +}); + test('normalizeVehicleState tolerates missing parts with nulls', () => { const state = normalizeVehicleState({ status: null, drivingRange: null, charging: null, airConditioning: null, position: null, health: null, maintenance: null }); assert.equal(state.locked, null); From eaed1a2c2600887dacdd4e5f56041172cc0a91e9 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:18:06 +0200 Subject: [PATCH 2/2] feat(skoda): admin toggle to show/hide the portal vehicles widget Mirrors the midea/smarthome portal-widget toggle: PUT branch, toggle in all three settings themes, settings.js wiring, i18n label. Flag still defaults on. --- public/js/settings.js | 8 ++++++-- src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/routes/api/settings/portal.js | 3 +++ templates/aurora/pages/settings.njk | 4 ++++ templates/default/pages/settings.njk | 4 ++++ templates/pro/pages/settings.njk | 4 ++++ tests/skoda_portal_setting.test.js | 27 +++++++++++++++++++++++++++ 8 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/skoda_portal_setting.test.js diff --git a/public/js/settings.js b/public/js/settings.js index f2a97953..f58e2182 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1781,11 +1781,12 @@ var widgetPihole = document.getElementById('portal-widget-pihole'); var widgetMidea = document.getElementById('portal-widget-midea'); var widgetSmarthome = document.getElementById('portal-widget-smarthome'); + var widgetSkoda = document.getElementById('portal-widget-skoda'); var trustToggle = document.getElementById('portal-trust-owner-mapping'); var autoappearToggle = document.getElementById('portal-autoappear'); if (!enabledToggle) return; - [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, widgetSmarthome, trustToggle, autoappearToggle].forEach(function (el) { + [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, widgetSmarthome, widgetSkoda, trustToggle, autoappearToggle].forEach(function (el) { if (el) el.addEventListener('click', function () { el.classList.toggle('on'); el.dispatchEvent(new Event('change')); @@ -1807,6 +1808,7 @@ setToggle(widgetPihole, d.widgets && d.widgets.pihole); setToggle(widgetMidea, d.widgets && d.widgets.midea); setToggle(widgetSmarthome, d.widgets && d.widgets.smarthome); + setToggle(widgetSkoda, d.widgets && d.widgets.skoda); setToggle(trustToggle, d.trustOwnerMapping); setToggle(autoappearToggle, d.autoappear !== false); if (window.SettingsAutosave && SettingsAutosave.resync) SettingsAutosave.resync('portal'); @@ -1814,7 +1816,7 @@ console.error('Failed to load portal settings:', err); }); - var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, widgetSmarthome, trustToggle, autoappearToggle].filter(Boolean); + var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, widgetSmarthome, widgetSkoda, trustToggle, autoappearToggle].filter(Boolean); SettingsAutosave.bind({ cluster: 'portal', fields: portalFields, @@ -1828,6 +1830,7 @@ 'portal-widget-pihole': widgetPihole ? widgetPihole.classList.contains('on') : true, 'portal-widget-midea': widgetMidea ? widgetMidea.classList.contains('on') : true, 'portal-widget-smarthome': widgetSmarthome ? widgetSmarthome.classList.contains('on') : true, + 'portal-widget-skoda': widgetSkoda ? widgetSkoda.classList.contains('on') : true, 'portal-trust-owner-mapping': trustToggle ? trustToggle.classList.contains('on') : false, 'portal-autoappear': autoappearToggle ? autoappearToggle.classList.contains('on') : true, }; @@ -1842,6 +1845,7 @@ pihole: widgetPihole ? widgetPihole.classList.contains('on') : true, midea: widgetMidea ? widgetMidea.classList.contains('on') : true, smarthome: widgetSmarthome ? widgetSmarthome.classList.contains('on') : true, + skoda: widgetSkoda ? widgetSkoda.classList.contains('on') : true, }, trust_owner_mapping: trustToggle ? trustToggle.classList.contains('on') : false, autoappear: autoappearToggle ? autoappearToggle.classList.contains('on') : true, diff --git a/src/i18n/de.json b/src/i18n/de.json index ab935aa8..ba3ae93d 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1942,6 +1942,7 @@ "settings.portal.widget_pihole": "Pi-hole-Widget", "settings.portal.widget_midea": "Klima-Widget", "settings.portal.widget_smarthome": "Smart Home", + "settings.portal.widget_skoda": "Fahrzeuge", "settings.portal.autoappear": "Portal automatisch öffnen beim Verbinden", "settings.portal.autoappear_help": "Wenn aktiv, öffnen VPN-Clients das Portal nach dem Verbinden automatisch. Deaktivieren, um das Portal erreichbar zu halten, ohne es automatisch zu öffnen.", "settings.portal.trust_owner_mapping": "Gerät→Besitzer-Vertrauen (pro-Besitzer ohne Login)", diff --git a/src/i18n/en.json b/src/i18n/en.json index dc9610d9..9f5baf4b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1998,6 +1998,7 @@ "settings.portal.widget_pihole": "Pi-hole widget", "settings.portal.widget_midea": "Climate widget", "settings.portal.widget_smarthome": "Smart Home", + "settings.portal.widget_skoda": "Vehicles", "settings.portal.autoappear": "Auto-open portal on connect", "settings.portal.autoappear_help": "When enabled, VPN clients automatically open the portal after connecting. Disable to keep the portal accessible but not auto-opened.", "settings.portal.trust_owner_mapping": "Trust device→owner mapping (zero-login per-owner)", diff --git a/src/routes/api/settings/portal.js b/src/routes/api/settings/portal.js index b909f48f..871a7c87 100644 --- a/src/routes/api/settings/portal.js +++ b/src/routes/api/settings/portal.js @@ -63,6 +63,9 @@ router.put('/portal', (req, res) => { if (widgets.smarthome !== undefined) { settings.set('portal.widget.smarthome', widgets.smarthome ? '1' : '0'); } + if (widgets.skoda !== undefined) { + settings.set('portal.widget.skoda', widgets.skoda ? '1' : '0'); + } if (body.trust_owner_mapping !== undefined) { settings.set('portal.trust_owner_mapping', body.trust_owner_mapping ? '1' : '0'); diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index 76d8fbfc..7cf294b3 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -992,6 +992,10 @@ {{ t('settings.portal.widget_smarthome') }}
+