release: 1.0.4 security hardening and stability fixes

Validate manual stream URLs (http/https only), restrict notification
cover downloads to HTTPS, and add transfer timeouts for metadata and
cover fetches. Document playback, tray, MPRIS, and QML refactor changes
in CHANGELOG and AppStream metadata.
This commit is contained in:
Sebastian Palencsar
2026-06-20 14:31:13 +02:00
parent cbd82e5ab1
commit 74cabebc4b
9 changed files with 91 additions and 2 deletions

View File

@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.0.4] - 2026-06-20
### Security
- Validate manual station URLs: only `http://` and `https://` schemes are accepted.
- Restrict notification cover art downloads to HTTPS URLs.
- Add network transfer timeouts to ICY metadata, cover art, and notification fetches.
### Fixed
- Fix playback index for history, resume, and next/previous navigation.
- Fix Radio Browser API race where stale responses could overwrite newer results.
- Suppress error banner when cached station data is still available.
- Fix search filter persisting across page changes and compact-mode debounce.
- Fix stale list index after station list reload.
- Fix Wayland system tray menu using native `QSystemTrayIcon`.
- Fix MPRIS metadata publishing for desktop media widgets (e.g. PlasMusic).
### Changed
- Refactor monolithic `Main.qml` into reusable QML components and `BearTheme`.
- Remove unused Kirigami, I18n, and CoreAddons dependencies.
- Add backend unit tests for playback navigation and API race handling.
## [1.0.3] - 2026-05-30 ## [1.0.3] - 2026-05-30
### Added ### Added

View File

@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
project(bearwave VERSION 1.0.3 LANGUAGES CXX) project(bearwave VERSION 1.0.4 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)

View File

@@ -59,6 +59,16 @@
</provides> </provides>
<releases> <releases>
<release version="1.0.4" date="2026-06-20">
<description>
<ul>
<li>Security: validate manual stream URLs (http/https only) and HTTPS-only cover downloads</li>
<li>Security: add network transfer timeouts for metadata and cover fetches</li>
<li>Fixed playback navigation, API race conditions, Wayland tray, and MPRIS metadata</li>
<li>Refactored QML UI into modular components</li>
</ul>
</description>
</release>
<release version="1.0.3" date="2026-05-30"> <release version="1.0.3" date="2026-05-30">
<description> <description>
<ul> <ul>

View File

@@ -42,6 +42,7 @@ void CoverArtFetcher::fetch(const QString &artist, const QString &title)
url.setQuery(query); url.setQuery(query);
QNetworkRequest request(url); QNetworkRequest request(url);
request.setTransferTimeout(10000);
m_currentReply = m_networkManager->get(request); m_currentReply = m_networkManager->get(request);
connect(m_currentReply, &QNetworkReply::finished, this, &CoverArtFetcher::onReplyFinished); connect(m_currentReply, &QNetworkReply::finished, this, &CoverArtFetcher::onReplyFinished);
} }

View File

@@ -30,6 +30,7 @@ void IcyReader::start(const QString &url)
request.setRawHeader("User-Agent", "VLC/3.0.16 LibVLC/3.0.16"); request.setRawHeader("User-Agent", "VLC/3.0.16 LibVLC/3.0.16");
// Streams often use 302 redirects to load balancers/relays // Streams often use 302 redirects to load balancers/relays
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
request.setTransferTimeout(15000);
m_reply = m_nam->get(request); m_reply = m_nam->get(request);

View File

@@ -25,7 +25,7 @@ int main(int argc, char *argv[])
QApplication::setApplicationName(QStringLiteral("BearWave")); QApplication::setApplicationName(QStringLiteral("BearWave"));
QApplication::setDesktopFileName(QStringLiteral("de.nerdbear.bearwave")); QApplication::setDesktopFileName(QStringLiteral("de.nerdbear.bearwave"));
QApplication::setOrganizationName(QStringLiteral("BearWave")); QApplication::setOrganizationName(QStringLiteral("BearWave"));
QApplication::setApplicationVersion(QStringLiteral("1.0.3")); QApplication::setApplicationVersion(QStringLiteral("1.0.4"));
QApplication app(argc, argv); QApplication app(argc, argv);

View File

@@ -123,6 +123,11 @@ void NotificationManager::onTrackInfoChanged()
void NotificationManager::downloadCover(const QString &url) void NotificationManager::downloadCover(const QString &url)
{ {
if (!url.startsWith(QLatin1String("https://"))) {
qDebug() << "Skipping cover download for non-HTTPS URL";
return;
}
if (m_currentReply) { if (m_currentReply) {
QNetworkReply *reply = m_currentReply; QNetworkReply *reply = m_currentReply;
m_currentReply = nullptr; m_currentReply = nullptr;
@@ -133,6 +138,7 @@ void NotificationManager::downloadCover(const QString &url)
m_pendingCoverUrl = url; m_pendingCoverUrl = url;
QNetworkRequest request(url); QNetworkRequest request(url);
request.setTransferTimeout(10000);
m_currentReply = m_networkManager->get(request); m_currentReply = m_networkManager->get(request);
connect(m_currentReply, &QNetworkReply::finished, this, &NotificationManager::onDownloadFinished); connect(m_currentReply, &QNetworkReply::finished, this, &NotificationManager::onDownloadFinished);
} }

View File

@@ -12,6 +12,7 @@
#include <QJsonObject> #include <QJsonObject>
#include <QStandardPaths> #include <QStandardPaths>
#include <QLocale> #include <QLocale>
#include <QUrl>
namespace { namespace {
constexpr int kRecentLimit = 20; constexpr int kRecentLimit = 20;
@@ -20,6 +21,16 @@ QString appConfigDir()
{ {
return QDir::homePath() + QStringLiteral("/.config/bearwave"); return QDir::homePath() + QStringLiteral("/.config/bearwave");
} }
bool isAllowedStreamUrl(const QString &urlString)
{
const QUrl url(urlString.trimmed());
if (!url.isValid() || url.isEmpty()) {
return false;
}
const QString scheme = url.scheme().toLower();
return scheme == QStringLiteral("http") || scheme == QStringLiteral("https");
}
} }
RadioBackend::RadioBackend(QObject *parent) RadioBackend::RadioBackend(QObject *parent)
@@ -409,7 +420,12 @@ void RadioBackend::addManualStation(const QString &name, const QString &url, con
if (name.trimmed().isEmpty() || url.trimmed().isEmpty()) { if (name.trimmed().isEmpty() || url.trimmed().isEmpty()) {
return; return;
} }
if (!isAllowedStreamUrl(url)) {
setLastError(tr("Stream URL must use http:// or https://"));
return;
}
setLastError(QString());
RadioStation *station = new RadioStation(this); RadioStation *station = new RadioStation(this);
station->setName(name.trimmed()); station->setName(name.trimmed());
station->setUrl(url.trimmed()); station->setUrl(url.trimmed());
@@ -848,7 +864,12 @@ void RadioBackend::editManualStation(QObject *stationObj, const QString &name, c
if (!station || name.trimmed().isEmpty() || url.trimmed().isEmpty()) { if (!station || name.trimmed().isEmpty() || url.trimmed().isEmpty()) {
return; return;
} }
if (!isAllowedStreamUrl(url)) {
setLastError(tr("Stream URL must use http:// or https://"));
return;
}
setLastError(QString());
QString oldUrl = station->url(); QString oldUrl = station->url();
QString oldUrlResolved = station->urlResolved(); QString oldUrlResolved = station->urlResolved();

View File

@@ -29,6 +29,8 @@ private slots:
void cached_load_keeps_error_clear_after_network_failure(); void cached_load_keeps_error_clear_after_network_failure();
void list_reload_resyncs_index_for_playing_station(); void list_reload_resyncs_index_for_playing_station();
void list_next_disabled_when_playing_station_not_in_new_list(); void list_next_disabled_when_playing_station_not_in_new_list();
void manual_station_rejects_unsafe_url_scheme();
void manual_station_accepts_http_and_https();
}; };
namespace { namespace {
@@ -302,5 +304,29 @@ void RadioBackendPlaybackTest::list_next_disabled_when_playing_station_not_in_ne
QCOMPARE(backend.currentStationUrl(), topUrl); QCOMPARE(backend.currentStationUrl(), topUrl);
} }
void RadioBackendPlaybackTest::manual_station_rejects_unsafe_url_scheme()
{
RadioBackend backend;
backend.addManualStation(QStringLiteral("Unsafe"), QStringLiteral("file:///etc/passwd"), QStringLiteral("DE"));
QCOMPARE(backend.stations().size(), 0);
QVERIFY(!backend.lastError().isEmpty());
backend.addManualStation(QStringLiteral("FTP"), QStringLiteral("ftp://example.com/stream"), QStringLiteral("DE"));
QCOMPARE(backend.stations().size(), 0);
}
void RadioBackendPlaybackTest::manual_station_accepts_http_and_https()
{
RadioBackend backend;
backend.addManualStation(QStringLiteral("HTTP"), QStringLiteral("http://example.com/stream"), QStringLiteral("DE"));
QCOMPARE(backend.stations().size(), 1);
QCOMPARE(backend.lastError(), QString());
backend.addManualStation(QStringLiteral("HTTPS"), QStringLiteral("https://example.com/stream"), QStringLiteral("DE"));
QCOMPARE(backend.stations().size(), 2);
}
QTEST_MAIN(RadioBackendPlaybackTest) QTEST_MAIN(RadioBackendPlaybackTest)
#include "radiobackend_playback_test.moc" #include "radiobackend_playback_test.moc"