Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions public/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -1807,14 +1808,15 @@
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');
}).catch(function (err) {
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,
Expand All @@ -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,
};
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
3 changes: 3 additions & 0 deletions src/routes/api/settings/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 7 additions & 5 deletions src/services/skoda/skodaClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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());
}
Expand Down
4 changes: 4 additions & 0 deletions templates/aurora/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,10 @@
<span class="l">{{ t('settings.portal.widget_smarthome') }}</span>
<div class="toggle" id="portal-widget-smarthome" data-managed="true"></div>
</div>
<div class="set-row">
<span class="l">{{ t('settings.portal.widget_skoda') }}</span>
<div class="toggle" id="portal-widget-skoda" data-managed="true"></div>
</div>
<div class="set-row">
<span class="l">{{ t('settings.portal.trust_owner_mapping') }}</span>
<div class="toggle" id="portal-trust-owner-mapping" data-managed="true"></div>
Expand Down
4 changes: 4 additions & 0 deletions templates/default/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,10 @@
<span style="font-size:13px">{{ t('settings.portal.widget_smarthome') }}</span>
<div class="toggle" id="portal-widget-smarthome" data-managed="true"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-base);border:1px solid var(--border);border-radius:var(--radius-sm)">
<span style="font-size:13px">{{ t('settings.portal.widget_skoda') }}</span>
<div class="toggle" id="portal-widget-skoda" data-managed="true"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-base);border:1px solid var(--border);border-radius:var(--radius-sm)">
<span style="font-size:13px">{{ t('settings.portal.trust_owner_mapping') }}</span>
<div class="toggle" id="portal-trust-owner-mapping" data-managed="true"></div>
Expand Down
4 changes: 4 additions & 0 deletions templates/pro/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,10 @@
<span style="font-size:13px">{{ t('settings.portal.widget_smarthome') }}</span>
<div class="toggle" id="portal-widget-smarthome" data-managed="true"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-base);border:1px solid var(--border);border-radius:var(--radius-sm)">
<span style="font-size:13px">{{ t('settings.portal.widget_skoda') }}</span>
<div class="toggle" id="portal-widget-skoda" data-managed="true"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-base);border:1px solid var(--border);border-radius:var(--radius-sm)">
<span style="font-size:13px">{{ t('settings.portal.trust_owner_mapping') }}</span>
<div class="toggle" id="portal-trust-owner-mapping" data-managed="true"></div>
Expand Down
12 changes: 12 additions & 0 deletions tests/skoda_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions tests/skoda_portal_setting.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use strict';
const crypto = require('crypto');
process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
const { test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup');

let portalConfig, settings;
beforeEach(async () => {
await setup();
portalConfig = require('../src/services/portalConfig');
settings = require('../src/services/settings');
});
afterEach(() => teardown());

test('widgets.skoda defaults to true', () => {
assert.equal(portalConfig().widgets.skoda, true);
});
test('widgets.skoda reflects the setting', () => {
settings.set('portal.widget.skoda', '0');
assert.equal(portalConfig().widgets.skoda, false);
});
test('settings API persists widgets.skoda', async () => {
const agent = getAgent(); const csrf = getCsrf();
await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf).send({ widgets: { skoda: false } }).expect(200);
assert.equal(settings.get('portal.widget.skoda', '1'), '0');
});
Loading