Compare commits

...
32 Commits
Author SHA1 Message Date
Safihre 7a5d715b94 Skip filesystem link behavior tests on Windows
These tests rely on a specific behavior of '..' in paths involving symbolic links, which differs on Windows where '..' is collapsed before the filesystem resolves links.
2026-09-08 12:15:18 +02:00
Safihre 888ee46b40 Pass Discord webhook secrets to release workflow 2026-09-08 11:13:54 +02:00
Safihre 737d61759b Update text files for 5.1.3 2026-09-08 11:11:06 +02:00
Safihre c62391e983 Add SafeUnpickler to guard against pickle-attacks (#3585)
* Add SafeUnpickler to guard against pickle-attacks

* Harden pickle unpickler with explicit allowlist

The previous `SafeUnpickler` allowed any class from `sabnzbd.*` by wildcard, which could still enable deserialization attacks if a "gadget class" (e.g., with a malicious `__del__` method) was present within our own package.

This commit renames the class to `RestrictedUnpickler` and changes its logic to only allow classes explicitly defined in `_SAFE_GLOBALS`. This significantly enhances security by preventing the unpickling of any unlisted classes, including those from within `sabnzbd`. Adds `os.stat_result` and `sabnzbd.nzb.*` classes to the allowlist for compatibility.
2026-09-08 10:57:02 +02:00
mnightingale 19e53229e0 Treat a dangling link as a taken name and refuse it as a write target 2026-09-08 10:57:01 +02:00
mnightingale d224d72814 Sanitize each part before checking it for traversal or the admin folder 2026-09-08 10:57:01 +02:00
mnightingale c4803e1c3a Pin the tarfile behaviour that contains members against links in the destination 2026-09-08 10:57:00 +02:00
mnightingale bc09facc17 Contain renames using the resolved paths so links cannot redirect them 2026-09-08 10:56:59 +02:00
mnightingale 86e989509f Skip links and other non-regular members when extracting tar files 2026-09-08 10:56:59 +02:00
mnightingale 02a748a807 Stop the page tree being reachable through private attributes 2026-09-08 10:55:54 +02:00
mnightingale 7bf6958d1d Prevent the API handler being reached through __wrapped__ 2026-09-08 10:55:50 +02:00
github-actions[bot]andSABnzbd Automation f39f99c827 7-Zip 26.03 (#3615)
Co-authored-by: SABnzbd Automation <bugs@sabnzbd.org>
2026-09-04 11:43:45 +02:00
Safihre febc1287bd Add Discord integration for release notes
Posts release notes automatically to designated Discord webhooks, supporting separate channels for pre-release and stable builds.
2026-09-04 11:43:39 +02:00
Safihre d1839d8c40 Improve NNTP error reporting with raw server messages
Instead of interpreting and translating common NNTP error messages (e.g., authentication failure, too many connections) into generic localized strings, this change passes the raw server response directly to the user. This provides more accurate and specific diagnostic information for troubleshooting server-related issues.
Closes #3563
2026-09-04 09:04:39 +02:00
mnightingale e3b62e0474 Safer open folder from macOS menu 2026-09-04 09:04:22 +02:00
Safihre 8b8342a378 Add tooltip to explain search filters in queue and history
Provide users with guidance on advanced search capabilities, including filter options like `cat:`, `status:`, and `priority:`.
Closes #3558
2026-09-04 09:04:07 +02:00
Safihre 2112dfbb23 Make Compact Great Again 2026-09-04 09:03:50 +02:00
Bryan Li 0fe4b31a5e Fix "database is locked" when re-evaluating stored RSS jobs (readout=False) 2026-09-04 08:48:44 +02:00
Safihre c49644f4e2 Automate LinuxServer.io Docker image builds
Trigger the corresponding LinuxServer.io Docker image build (develop for
pre-releases, master for stable) after a SABnzbd release, ensuring the
Docker images are kept up-to-date.
2026-08-27 11:55:31 +02:00
Safihre 919bcbf5a3 Update text files for 5.1.2 2026-08-25 16:21:59 +02:00
Safihre 6525703a94 Restrict mode-based API key logic to actual API routes
The `mode` parameter in API calls enables specific behaviors and key bypasses (e.g., 'version', 'auth', NZB-key). Previously, this logic was applied universally by `check_apikey`. This change ensures such `mode`-based logic is only honored when accessing the dedicated `/api` endpoint, preventing potential API key bypasses on other web UI routes.
2026-08-25 13:24:45 +02:00
Safihre a0e2408933 Sanitize PP-values 2026-08-25 13:24:37 +02:00
mnightingale 79e37e6b73 Fix applying changes to RSS filters (#3582) 2026-08-24 14:35:35 +02:00
Safihre c57af32131 Harden paths that could overwrite __ADMIN__ files
Prevent directory creation in `renamer` to overwrite `__ADMIN__`
Check the whole new path for `__ADMIN__` in `renamer`
Drop `__ADMIN__` from names that are allowed to have sub-directories
Resolve the path before checking it against `__ADMIN__`
2026-08-24 10:41:42 +02:00
Safihre 6ddabb5a45 Prevent directory traversal in Deobfuscate 2026-08-24 09:04:36 +02:00
Safihre 8848e5b2fb Update text files for 5.1.1 2026-08-18 17:28:04 +02:00
Safihre 9a12300877 Logout should not send actual cookies
And increase secret enthropy
2026-08-18 17:28:03 +02:00
mnightingale 31e052796d Fix rss "Clear Downloaded" did not appear to do anything (#3569) 2026-08-18 12:31:00 +02:00
mnightingale 9ae6b71d2b Fix rss clearing at startup and downloaded items still in the feed (#3576)
* Only purge unconfigured rss feeds at startup

* Only perform remove obsolete actions if the feed was read

* Update seen_at so downloaded items are not removed while still in the feed

* Increase hardcoded retention to 7 days
2026-08-18 11:47:13 +02:00
Safihre 944816e765 Add static cache busting for Config Auto colorscheme 2026-08-18 08:49:55 +02:00
Safihre 3e75a76602 Add static cache busting for Auto colorscheme
Yes ugly, but it works for major updates
2026-08-18 08:49:13 +02:00
Safihre 54e868dd72 Update text files for 5.1.0 2026-08-10 09:36:48 +02:00
49 changed files with 1520 additions and 346 deletions

No files matched your search

+3
View File
@@ -214,6 +214,9 @@ jobs:
env:
AUTOMATION_GITHUB_TOKEN: ${{ secrets.AUTOMATION_GITHUB_TOKEN }}
REDDIT_TOKEN: ${{ secrets.REDDIT_TOKEN }}
LINUXSERVER_WEBHOOK_TOKEN: ${{ secrets.LINUXSERVER_WEBHOOK_TOKEN }}
DISCORD_WEBHOOK_TESTING: ${{ secrets.DISCORD_WEBHOOK_TESTING }}
DISCORD_WEBHOOK_STABLE: ${{ secrets.DISCORD_WEBHOOK_STABLE }}
run: |
pip3 install -r builder/release-requirements.txt --no-dependencies
python3 builder/release.py
+61 -2
View File
@@ -1,7 +1,64 @@
Release Notes - SABnzbd 5.1.0 Release Candidate 2
Release Notes - SABnzbd 5.1.3
=========================================================
This is the second release candidate of version 5.1.
## Security fixes
SABnzbd 5.1.1, 5.1.2 and 5.1.3 resolve a number of security vulnerabilities.
Over the past releases, several security researchers have put a lot of time and effort
examining SABnzbd, responsibly reporting their findings, and helping us verify the fixes.
We are grateful for their hard work, it has made SABnzbd meaningfully safer for everyone.
For most users the risk is limited: you are only affected if your setup is exposed to
untrusted parties. The web interface issues only apply if it can be reached by someone
you do not trust. By default, SABnzbd is only accessible from your own device and
`External internet access` is set to `No access`. If either of those is still at its
default, or if you use a proxy service for authentication, you are not affected.
The download-processing issues only apply if SABnzbd handles NZBs from a source you
do not fully trust, such as a public indexer.
If none of that describes your setup, you were never at risk.
The safest action is simply to update to 5.1.3.
Full details, affected versions, actions and mitigations for each vulnerability are described at:
https://sabnzbd.org/5-1-vulnerabilities
Vulnerabilities resolved:
* GHSA-q326-jpxx-jmjc: `__wrapped__` dispatch bypass allows unauthenticated API access, fixed in 5.1.3.
* GHSA-mjwj-v5mr-cmcg: PAR2 symlink bypass allows pickle remote code execution, fixed in 5.1.3.
* GHSA-rgqj-28c2-gxwp: Critical remote code execution via authentication bypass on configuration endpoints, fixed in 5.1.2.
* GHSA-75g3-96fr-7p2r: High-severity path traversal during post-processing via crafted PAR2/SFV files, fixed in 5.1.2.
* GHSA-xrfq-jhgh-wqch: Critical authentication bypass allowing a valid session without credentials, fixed in 5.1.1.
If you rely on the SABnzbd username and password to keep out untrusted parties, and your
setup was exposed, it is recommended that you rotate your SABnzbd username/password and
API-key, Usenet server passwords, indexer API-keys used in RSS-feeds, and notification
service credentials after updating. See the page above for the full guidance.
## Other changes and bug fixes in 5.1.3
* Tighten Compact display mode.
* Directly show usenet server errors instead of parsing them.
* Prevent `database is locked` error when re-evaluating stored RSS jobs.
* Added tooltip to explain search filters in Queue and History.
* Windows and macOS: Updated 7zip to 26.03.
## Other bug fixes in 5.1.2
* RSS filters were not applied directly.
## Other bug fixes in 5.1.1
* Styling cache issues could occur after updating.
* RSS items were removed too eagerly from RSS-history after download.
* The RSS-feed `Clear Downloaded` button did not do anything.
## Changelog 5.1.0
This release brings a fundamental improvement to "Retry": instead of
re-downloading any files with missing data, only the articles that were actually
missing are fetched again. RSS got an overhaul under the hood, the interface is
refreshed, and we added quite a long list of long-requested features and bugfixes.
## New features in 5.1.0
@@ -39,10 +96,12 @@ This is the second release candidate of version 5.1.
* Diskspace checks would be too strict when unpacking to different disk.
* Prevent deadlock when a second signal arrives during shutdown procedure.
* Verification using SFV-checks could fail, even though files were correct.
* Don't log Warning during clean shutdowns.
* Windows: Uninstall would not remove Settings or Windows Service.
* Windows: Warn for OS-limit if more than 1024 connections are configured.
* macOS: AppleDouble files could result in failures in `Moving` stage.
* macOS: Restarting the application resulted in Terminal window.
* macOS: Included par2cmdline-turbo will now use all available CPU-features.
## Upgrade notices
+1
View File
@@ -1325,6 +1325,7 @@ def main():
}
appconfig = {
"/": {"request.dispatch": sabnzbd.interface.SecureDispatcher()},
"/api": {
"tools.auth_basic.on": False,
"tools.response_headers.on": True,
+43
View File
@@ -21,6 +21,7 @@ import re
import github
import praw
import requests
from common import (
RELEASE_VERSION,
@@ -186,6 +187,20 @@ if RELEASE_THIS and gh_token:
print("Merging pull request in sabnzbd/sabnzbd.github.io for the update")
update_pr.merge(merge_method="squash")
# Trigger the Docker image build at linuxserver.io
# Branch "develop" builds the pre-releases, "master" the stable releases
if linuxserver_token := os.environ.get("LINUXSERVER_WEBHOOK_TOKEN", ""):
linuxserver_branch = "develop" if PRERELEASE else "master"
print("Triggering linuxserver.io Docker build for branch %s" % linuxserver_branch)
requests.post(
"https://ci.linuxserver.io/generic-webhook-trigger/invoke?sabnzbd",
headers={"Authorization": "Bearer %s" % linuxserver_token},
json={"branch": linuxserver_branch},
timeout=30,
).raise_for_status()
else:
print("Missing LINUXSERVER_WEBHOOK_TOKEN")
# Only with GitHub success we proceed to Reddit
if reddit_token := os.environ.get("REDDIT_TOKEN", ""):
# Token format (without whitespace):
@@ -233,6 +248,34 @@ if RELEASE_THIS and gh_token:
else:
print("Missing REDDIT_TOKEN")
# Push release notes to Discord via an incoming webhook
# Separate channels for testing (pre-release) and stable, both are required
discord_webhook_testing = os.environ.get("DISCORD_WEBHOOK_TESTING", "")
discord_webhook_stable = os.environ.get("DISCORD_WEBHOOK_STABLE", "")
if discord_webhook_testing and discord_webhook_stable:
discord_webhook = discord_webhook_testing if PRERELEASE else discord_webhook_stable
print("Posting release notes to Discord")
# Reuse the release notes read earlier: line 0 is the title header
discord_lines = readme_data.splitlines(keepends=True)
discord_title = discord_lines[0].lstrip("# ").strip()
discord_body = "".join(discord_lines[2:])[:4096]
requests.post(
discord_webhook,
json={
"embeds": [
{
"title": discord_title,
"url": gh_release.html_url,
"description": discord_body,
"color": 0xFFA500 if PRERELEASE else 0x00A550,
}
]
},
timeout=30,
).raise_for_status()
else:
print("Missing DISCORD_WEBHOOK_TESTING and/or DISCORD_WEBHOOK_STABLE")
else:
print("To push release to GitHub, first tag the commit.")
print("Or missing the AUTOMATION_GITHUB_TOKEN, cannot push to GitHub without it.")
@@ -1 +1 @@
@import url('Night.css') screen and (prefers-color-scheme: dark);
@import url('Night.css?lastmajorupdate=510') screen and (prefers-color-scheme: dark);
@@ -138,7 +138,7 @@
<div class="input-group search-box">
<input type="text" class="form-control" placeholder="$T('Glitter-search')" aria-label="$T('Glitter-search')" required data-bind="textInput: history.searchTerm, event: { keydown: history.clearSearchTerm }" />
<a href="#" data-bind="click: history.clearSearchTerm, attr: { 'aria-label': history.searchTerm() ? '$T('Glitter-clearSearch')' : '$T('Glitter-search')' }">
<a href="#" data-tooltip="true" data-html="true" data-placement="right" title="$T('Glitter-searchExplain')" data-bind="click: history.clearSearchTerm, attr: { 'aria-label': history.searchTerm() ? '$T('Glitter-clearSearch')' : '$T('Glitter-search')' }">
<span class="glyphicon" data-bind="css: { 'glyphicon-search' : !history.searchTerm(), 'glyphicon-remove' : history.searchTerm() }"></span>
</a>
</div>
@@ -232,7 +232,7 @@
<div class="input-group search-box" data-bind="visible: queue.hasQueueSearch" style="display: none;">
<input type="text" class="form-control" placeholder="$T('Glitter-search')" aria-label="$T('Glitter-search')" required data-bind="textInput: queue.searchTerm, event: { keydown: queue.clearSearchTerm }" />
<a href="#" data-bind="click: queue.clearSearchTerm, attr: { 'aria-label': queue.searchTerm() ? '$T('Glitter-clearSearch')' : '$T('Glitter-search')' }">
<a href="#" data-tooltip="true" data-html="true" data-placement="right" title="$T('Glitter-searchExplain')" data-bind="click: queue.clearSearchTerm, attr: { 'aria-label': queue.searchTerm() ? '$T('Glitter-clearSearch')' : '$T('Glitter-search')' }">
<span class="glyphicon" data-bind="css: { 'glyphicon-search' : !queue.searchTerm(), 'glyphicon-remove' : queue.searchTerm() }"></span>
</a>
</div>
@@ -1 +1 @@
@import url('Night.css') screen and (prefers-color-scheme: dark);
@import url('Night.css?lastmajorupdate=510') screen and (prefers-color-scheme: dark);
@@ -40,12 +40,15 @@ h2 {
.container {
margin-top: 1em;
padding-left: 0;
padding-right: 0;
}
.main-navbar {
margin-bottom: 2px;
z-index: 999;
padding-left: 0;
padding-right: 0;
}
.navbar.main-navbar {
@@ -1978,8 +1981,34 @@ input[name="nzbURL"] {
padding-top: 5px;
}
/* Tighten the gaps between the main containers */
.container-compact .main-navbar {
margin-top: 3px;
}
.container-compact:not(.container-tabbed) .main-content {
padding-top: 0;
margin-bottom: 15px;
}
.container-compact .queue,
.container-compact .history,
.container-compact .queue-messages {
padding: 4px 6px;
margin-bottom: 6px;
}
.container-compact .queue-footer,
.container-compact .history-footer {
padding: 0;
}
.container-compact .table {
margin-bottom: 3px;
}
.container-full-width .container {
width: calc(100% - 60px);
width: calc(100% - 40px);
}
/***
+242 -233
View File
@@ -1,237 +1,246 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright 2022-2026 by The SABnzbd-Team (sabnzbd.org) -->
<component type="desktop-application">
<id>org.sabnzbd.sabnzbd</id>
<metadata_license>MIT</metadata_license>
<name>SABnzbd</name>
<summary>Free and easy binary newsreader</summary>
<summary xml:lang="fr">Lecteur de news binaire libre et facile à utiliser</summary>
<summary xml:lang="tr">Özgür ve kullanımı kolay Usenet ikili okuyucu</summary>
<branding>
<color type="primary" scheme_preference="light">#e7e7e7</color>
<color type="primary" scheme_preference="dark">#444444</color>
</branding>
<description>
<p>
SABnzbd is a free and Open Source web-based binary newsreader,
with support for the popular nzb file format. It greatly simplifies
the process of downloading from Usenet, thanks to a friendly
web-based user interface and advanced built-in post-processing
options including the ability to automatically verify, repair,
extract and clean up downloaded posts. It runs anywhere, comes in
over a dozen languages, and integrates with a host of tools, apps
and services that help automate the download process.
</p>
<p xml:lang="fr">
SABnzbd est un lecteur binaire de newsgroups libre et gratuit prenant
en charge le format nzb très répandu. Il simplifie grandement le processus
de téléchargement depuis Usenet grâce à une interface web conviviale
et à des options avancées de post-traitement intégrées qui comprennent
la capacité à vérifier, réparer, extraire et nettoyer automatiquement
les messages téléchargés. Il fonctionne partout, est fourni avec une
douzaine de traductions et s'intègre avec de nombreux outils, applications
et services qui aident à automatiser le processus de téléchargement.
</p>
<p xml:lang="tr">
SABnzbd, popüler nzb biçimini destekleyen özgür ve ücretsiz bir ikili
haber grupları okuyucusudur. Kullanıcı dostu Web tabanlı arayüzü ve
indirilen mesajları otomatik olarak doğrulama, tamir etme, çıkarma ve
temizleme kabiliyetine sahip gelişmiş yerleşik son işlem seçenekleri
sayesinde Usenet ağından indirmeyi büyük ölçüde kolaylaştırır. Her yerde
çalışabilir, bir düzineden fazla dil desteği sunar ve indirme sürecini
otomatik hâle getirmeye yardımcı olan birçok araç, uygulama ve hizmet ile
birlikte çalışabilir.
</p>
</description>
<keywords>
<keyword>usenet</keyword>
<keyword>nzb</keyword>
<keyword>download</keyword>
<keyword>newsreader</keyword>
<keyword>binary</keyword>
<keyword xml:lang="fr">usenet</keyword>
<keyword xml:lang="fr">nzb</keyword>
<keyword xml:lang="fr">téléchargement</keyword>
<keyword xml:lang="fr">binaires</keyword>
<keyword xml:lang="tr">usenet</keyword>
<keyword xml:lang="tr">nzb</keyword>
<keyword xml:lang="tr">indirme</keyword>
</keywords>
<categories>
<category>Network</category>
<category>FileTransfer</category>
</categories>
<url type="homepage">https://sabnzbd.org</url>
<url type="bugtracker">https://github.com/sabnzbd/sabnzbd/issues</url>
<url type="vcs-browser">https://github.com/sabnzbd/sabnzbd</url>
<url type="contribute">https://github.com/sabnzbd/sabnzbd</url>
<url type="translate">https://sabnzbd.org/wiki/translate</url>
<url type="donation">https://sabnzbd.org/donate</url>
<url type="help">https://sabnzbd.org/wiki/</url>
<url type="faq">https://sabnzbd.org/wiki/faq</url>
<url type="contact">https://sabnzbd.org/live-chat.html</url>
<releases>
<release version="5.1.0" date="2026-08-01" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.0</url>
</release>
<release version="5.0.4" date="2026-06-11" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.4</url>
</release>
<release version="5.0.3" date="2026-05-15" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.3</url>
</release>
<release version="5.0.2" date="2026-05-14" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.2</url>
</release>
<release version="5.0.1" date="2026-05-01" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.1</url>
</release>
<release version="5.0.0" date="2026-05-01" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.0</url>
</release>
<release version="4.5.5" date="2025-10-24" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.5</url>
</release>
<release version="4.5.4" date="2025-10-22" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.4</url>
</release>
<release version="4.5.3" date="2025-08-25" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.3</url>
</release>
<release version="4.5.2" date="2025-07-09" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.2</url>
</release>
<release version="4.5.1" date="2025-04-11" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.1</url>
</release>
<release version="4.5.0" date="2025-04-01" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.0</url>
</release>
<release version="4.4.1" date="2024-12-23" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.4.1</url>
</release>
<release version="4.4.0" date="2024-12-09" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.4.0</url>
</release>
<release version="4.3.3" date="2024-08-01" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.3</url>
</release>
<release version="4.3.2" date="2024-05-30" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.2</url>
</release>
<release version="4.3.1" date="2024-05-03" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.1</url>
</release>
</releases>
<launchable type="desktop-id">sabnzbd.desktop</launchable>
<provides>
<mediatype>application/x-nzb</mediatype>
<mediatype>application/x-compressed-nzb</mediatype>
</provides>
<supports>
<control>pointing</control>
<control>keyboard</control>
<control>touch</control>
</supports>
<recommends>
<display_length compare="ge">640</display_length>
<internet>always</internet>
</recommends>
<project_license>GPL-2.0-or-later</project_license>
<developer id="org.sabnzbd">
<name>The SABnzbd-Team</name>
</developer>
<screenshots>
<screenshot type="default">
<image>https://sabnzbd.org/images/landing/screenshots/interface.png</image>
<caption>Intuitive interface</caption>
<caption xml:lang="fr">Interface intuitive</caption>
<caption xml:lang="tr">Sezgisel arayüz</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/night-mode.png</image>
<caption>Also comes in Night-mode</caption>
<caption xml:lang="fr">Fourni également avec le mode nuit</caption>
<caption xml:lang="tr">Gece kipi ile de sunulur</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/add-nzb.png</image>
<caption>Add NZB's or use drag-and-drop!</caption>
<caption xml:lang="fr">Ajoutez des fichiers NZB ou utilisez le glisser - déposer !</caption>
<caption xml:lang="tr">NZB dosyaları ekleyin veya kaydır - bırak kullanın!</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/phone-interface.png</image>
<caption>Scales to any screen size</caption>
<caption xml:lang="fr">S'adapte à toutes les tailles d'écran</caption>
<caption xml:lang="tr">Her ekran boyutuna uyum sağlar</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/history-details.png</image>
<caption>Easy overview of all history details</caption>
<caption xml:lang="fr">Vue d'ensemble aisée de tous les détails de l'historique</caption>
<caption xml:lang="tr">Tarihçenin tüm ayrıntıları için kolay genel bakış</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/phone-extra.png</image>
<caption>Every option, on every screen size</caption>
<caption xml:lang="fr">Toutes les options, quelle que soit la taille d'écran</caption>
<caption xml:lang="tr">Ekran boyutu ne olursa olsun, tüm seçenekler elinizin altında</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/file-lists.png</image>
<caption>Manage a job's individual files</caption>
<caption xml:lang="fr">Gérez les fichiers individuels d'une tâche</caption>
<caption xml:lang="tr">Herhangi bir görevin dosyalarını tek tek yönetin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/set-speedlimit.png</image>
<caption>Easy speed limiting</caption>
<caption xml:lang="fr">Limitation facile du débit</caption>
<caption xml:lang="tr">Kolay hız sınırlaması</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/set-options.png</image>
<caption>Quickly change settings</caption>
<caption xml:lang="fr">Changement rapide des réglages</caption>
<caption xml:lang="tr">Ayarları hızlıca değiştirin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/dashboard.png</image>
<caption>Easy system check</caption>
<caption xml:lang="fr">Vérification facile du système</caption>
<caption xml:lang="tr">Sistemi kolayca kontrol edin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/connections-overview.png</image>
<caption>See active connections</caption>
<caption xml:lang="fr">Voyez les connexions actives</caption>
<caption xml:lang="tr">Etkin bağlantıları görüntüleyin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/skin-settings.png</image>
<caption>Customize the interface</caption>
<caption xml:lang="fr">Personnalisez l'interface</caption>
<caption xml:lang="tr">Arayüzü kişiselleştirin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/tabbed.png</image>
<caption>Tabbed-mode</caption>
<caption xml:lang="fr">Mode avec onglets</caption>
<caption xml:lang="tr">Sekmeli kip</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/set-custom-pause.png</image>
<caption>Specify any pause duration</caption>
<caption xml:lang="fr">Spécifiez une durée de pause quelconque</caption>
<caption xml:lang="tr">Herhangi bir duraklama süresi belirtin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/config.png</image>
<caption>Easy configuration</caption>
<caption xml:lang="fr">Configuration facile</caption>
<caption xml:lang="tr">Kolay yapılandırma</caption>
</screenshot>
</screenshots>
<content_rating type="oars-1.1"/>
<id>org.sabnzbd.sabnzbd</id>
<metadata_license>MIT</metadata_license>
<name>SABnzbd</name>
<summary>Free and easy binary newsreader</summary>
<summary xml:lang="fr">Lecteur de news binaire libre et facile à utiliser</summary>
<summary xml:lang="tr">Özgür ve kullanımı kolay Usenet ikili okuyucu</summary>
<branding>
<color type="primary" scheme_preference="light">#e7e7e7</color>
<color type="primary" scheme_preference="dark">#444444</color>
</branding>
<description>
<p>
SABnzbd is a free and Open Source web-based binary newsreader,
with support for the popular nzb file format. It greatly simplifies
the process of downloading from Usenet, thanks to a friendly
web-based user interface and advanced built-in post-processing
options including the ability to automatically verify, repair,
extract and clean up downloaded posts. It runs anywhere, comes in
over a dozen languages, and integrates with a host of tools, apps
and services that help automate the download process.
</p>
<p xml:lang="fr">
SABnzbd est un lecteur binaire de newsgroups libre et gratuit prenant
en charge le format nzb très répandu. Il simplifie grandement le processus
de téléchargement depuis Usenet grâce à une interface web conviviale
et à des options avancées de post-traitement intégrées qui comprennent
la capacité à vérifier, réparer, extraire et nettoyer automatiquement
les messages téléchargés. Il fonctionne partout, est fourni avec une
douzaine de traductions et s'intègre avec de nombreux outils, applications
et services qui aident à automatiser le processus de téléchargement.
</p>
<p xml:lang="tr">
SABnzbd, popüler nzb biçimini destekleyen özgür ve ücretsiz bir ikili
haber grupları okuyucusudur. Kullanıcı dostu Web tabanlı arayüzü ve
indirilen mesajları otomatik olarak doğrulama, tamir etme, çıkarma ve
temizleme kabiliyetine sahip gelişmiş yerleşik son işlem seçenekleri
sayesinde Usenet ağından indirmeyi büyük ölçüde kolaylaştırır. Her yerde
çalışabilir, bir düzineden fazla dil desteği sunar ve indirme sürecini
otomatik hâle getirmeye yardımcı olan birçok araç, uygulama ve hizmet ile
birlikte çalışabilir.
</p>
</description>
<keywords>
<keyword>usenet</keyword>
<keyword>nzb</keyword>
<keyword>download</keyword>
<keyword>newsreader</keyword>
<keyword>binary</keyword>
<keyword xml:lang="fr">usenet</keyword>
<keyword xml:lang="fr">nzb</keyword>
<keyword xml:lang="fr">téléchargement</keyword>
<keyword xml:lang="fr">binaires</keyword>
<keyword xml:lang="tr">usenet</keyword>
<keyword xml:lang="tr">nzb</keyword>
<keyword xml:lang="tr">indirme</keyword>
</keywords>
<categories>
<category>Network</category>
<category>FileTransfer</category>
</categories>
<url type="homepage">https://sabnzbd.org</url>
<url type="bugtracker">https://github.com/sabnzbd/sabnzbd/issues</url>
<url type="vcs-browser">https://github.com/sabnzbd/sabnzbd</url>
<url type="contribute">https://github.com/sabnzbd/sabnzbd</url>
<url type="translate">https://sabnzbd.org/wiki/translate</url>
<url type="donation">https://sabnzbd.org/donate</url>
<url type="help">https://sabnzbd.org/wiki/</url>
<url type="faq">https://sabnzbd.org/wiki/faq</url>
<url type="contact">https://sabnzbd.org/live-chat.html</url>
<releases>
<release version="5.1.3" date="2026-08-25" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.2</url>
</release>
<release version="5.1.2" date="2026-08-25" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.2</url>
</release>
<release version="5.1.1" date="2026-08-18" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.1</url>
</release>
<release version="5.1.0" date="2026-08-10" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.0</url>
</release>
<release version="5.0.4" date="2026-06-11" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.4</url>
</release>
<release version="5.0.3" date="2026-05-15" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.3</url>
</release>
<release version="5.0.2" date="2026-05-14" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.2</url>
</release>
<release version="5.0.1" date="2026-05-01" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.1</url>
</release>
<release version="5.0.0" date="2026-05-01" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.0</url>
</release>
<release version="4.5.5" date="2025-10-24" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.5</url>
</release>
<release version="4.5.4" date="2025-10-22" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.4</url>
</release>
<release version="4.5.3" date="2025-08-25" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.3</url>
</release>
<release version="4.5.2" date="2025-07-09" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.2</url>
</release>
<release version="4.5.1" date="2025-04-11" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.1</url>
</release>
<release version="4.5.0" date="2025-04-01" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.0</url>
</release>
<release version="4.4.1" date="2024-12-23" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.4.1</url>
</release>
<release version="4.4.0" date="2024-12-09" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.4.0</url>
</release>
<release version="4.3.3" date="2024-08-01" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.3</url>
</release>
<release version="4.3.2" date="2024-05-30" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.2</url>
</release>
<release version="4.3.1" date="2024-05-03" type="stable">
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.1</url>
</release>
</releases>
<launchable type="desktop-id">sabnzbd.desktop</launchable>
<provides>
<mediatype>application/x-nzb</mediatype>
<mediatype>application/x-compressed-nzb</mediatype>
</provides>
<supports>
<control>pointing</control>
<control>keyboard</control>
<control>touch</control>
</supports>
<recommends>
<display_length compare="ge">640</display_length>
<internet>always</internet>
</recommends>
<project_license>GPL-2.0-or-later</project_license>
<developer id="org.sabnzbd">
<name>The SABnzbd-Team</name>
</developer>
<screenshots>
<screenshot type="default">
<image>https://sabnzbd.org/images/landing/screenshots/interface.png</image>
<caption>Intuitive interface</caption>
<caption xml:lang="fr">Interface intuitive</caption>
<caption xml:lang="tr">Sezgisel arayüz</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/night-mode.png</image>
<caption>Also comes in Night-mode</caption>
<caption xml:lang="fr">Fourni également avec le mode nuit</caption>
<caption xml:lang="tr">Gece kipi ile de sunulur</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/add-nzb.png</image>
<caption>Add NZB's or use drag-and-drop!</caption>
<caption xml:lang="fr">Ajoutez des fichiers NZB ou utilisez le glisser - déposer !</caption>
<caption xml:lang="tr">NZB dosyaları ekleyin veya kaydır - bırak kullanın!</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/phone-interface.png</image>
<caption>Scales to any screen size</caption>
<caption xml:lang="fr">S'adapte à toutes les tailles d'écran</caption>
<caption xml:lang="tr">Her ekran boyutuna uyum sağlar</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/history-details.png</image>
<caption>Easy overview of all history details</caption>
<caption xml:lang="fr">Vue d'ensemble aisée de tous les détails de l'historique</caption>
<caption xml:lang="tr">Tarihçenin tüm ayrıntıları için kolay genel bakış</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/phone-extra.png</image>
<caption>Every option, on every screen size</caption>
<caption xml:lang="fr">Toutes les options, quelle que soit la taille d'écran</caption>
<caption xml:lang="tr">Ekran boyutu ne olursa olsun, tüm seçenekler elinizin altında</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/file-lists.png</image>
<caption>Manage a job's individual files</caption>
<caption xml:lang="fr">Gérez les fichiers individuels d'une tâche</caption>
<caption xml:lang="tr">Herhangi bir görevin dosyalarını tek tek yönetin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/set-speedlimit.png</image>
<caption>Easy speed limiting</caption>
<caption xml:lang="fr">Limitation facile du débit</caption>
<caption xml:lang="tr">Kolay hız sınırlaması</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/set-options.png</image>
<caption>Quickly change settings</caption>
<caption xml:lang="fr">Changement rapide des réglages</caption>
<caption xml:lang="tr">Ayarları hızlıca değiştirin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/dashboard.png</image>
<caption>Easy system check</caption>
<caption xml:lang="fr">Vérification facile du système</caption>
<caption xml:lang="tr">Sistemi kolayca kontrol edin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/connections-overview.png</image>
<caption>See active connections</caption>
<caption xml:lang="fr">Voyez les connexions actives</caption>
<caption xml:lang="tr">Etkin bağlantıları görüntüleyin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/skin-settings.png</image>
<caption>Customize the interface</caption>
<caption xml:lang="fr">Personnalisez l'interface</caption>
<caption xml:lang="tr">Arayüzü kişiselleştirin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/tabbed.png</image>
<caption>Tabbed-mode</caption>
<caption xml:lang="fr">Mode avec onglets</caption>
<caption xml:lang="tr">Sekmeli kip</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/set-custom-pause.png</image>
<caption>Specify any pause duration</caption>
<caption xml:lang="fr">Spécifiez une durée de pause quelconque</caption>
<caption xml:lang="tr">Herhangi bir duraklama süresi belirtin</caption>
</screenshot>
<screenshot>
<image>https://sabnzbd.org/images/landing/screenshots/config.png</image>
<caption>Easy configuration</caption>
<caption xml:lang="fr">Configuration facile</caption>
<caption xml:lang="tr">Kolay yapılandırma</caption>
</screenshot>
</screenshots>
<content_rating type="oars-1.1"/>
</component>
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -533,8 +533,8 @@ def delayed_startup_actions():
# aren't on 24/7 and typically don't benefit from the daily scheduled call at midnight
sabnzbd.database.scheduled_history_purge()
# Purge links older than 3 days
sabnzbd.rss.expired_purge()
# Drop leftover records of feeds that are no longer configured
sabnzbd.rss.purge_removed_feeds()
# Start SSDP and Bonjour if SABnzbd isn't listening on localhost only
if sabnzbd.cfg.enable_broadcast() and not misc.is_localhost(cfg.web_host()):
+3 -4
View File
@@ -1512,10 +1512,9 @@ def test_nntp_server_dict(kwargs: ApiParams) -> tuple[bool, str]:
elif nntp_code < 300 or nntp_code in (411, 423, 430):
# If no username/password set and we requested fake-article, it will return 430 Not Found
return_status = (True, T("Connection Successful!"))
elif nntp_code == 502 or sabnzbd.downloader.clues_login(nntp_message):
return_status = (False, T("Authentication failed, check username/password."))
elif sabnzbd.downloader.clues_too_many(nntp_message):
return_status = (False, T("Too many connections, please pause downloading or try again later"))
else:
# Pass the server message directly on to the user
return_status = (False, nntp_message)
# Fallback in case no data was received or unknown status
if not return_status:
+6 -1
View File
@@ -72,7 +72,12 @@ def decode_par2(parfile: str) -> list[str]:
new_path = os.path.join(dirname, md5of16k[file_md5of16k])
# Make sure it's a unique name
unique_filename = get_unique_filename(new_path)
renamer(filepath, unique_filename)
# Untrusted par2 name: contain it, skip on traversal
try:
renamer(filepath, unique_filename, create_local_directories=True)
except OSError:
logging.info("Skipping par2 rename of %s to %s", filepath, unique_filename)
continue
new_files.append(unique_filename)
return new_files
+4 -8
View File
@@ -864,31 +864,27 @@ class Downloader(Thread):
# Handle login problems
block = False
penalty = 0
errormsg = None
# Pass the server message directly on to the user
errormsg = T("Server %s reported: %s") % (server.host, error.msg)
logging.debug("Server login problem: %s", error.msg)
if error.code in (502, 400, 481, 482) and clues_too_many(error.msg):
# Too many connections: remove this thread and reduce thread-setting for server
# Plan to go back to the full number after a penalty timeout
errormsg = T("Too many connections to server %s [%s]") % (server.host, error.msg)
if server.active:
# Don't count this for the tries (max_art_tries) on this server
self.reset_nw(nw)
self.plan_server(server, _PENALTY_TOOMANY)
elif error.code in (502, 481, 482) and clues_too_many_ip(error.msg):
# Login from (too many) different IP addresses
errormsg = T(
"Login from too many different IP addresses to server %s [%s] - https://sabnzbd.org/multiple-adresses"
) % (server.host, error.msg)
errormsg += " - https://sabnzbd.org/multiple-adresses"
penalty = _PENALTY_SHARE
block = True
elif error.code in (452, 481, 482, 381) or (error.code in (500, 502) and clues_login(error.msg)):
# Cannot login, block this server
errormsg = T("Failed login for server %s [%s]") % (server.host, error.msg)
penalty = _PENALTY_PERM
block = True
elif error.code in (502, 482):
# Cannot connect (other reasons), block this server
errormsg = T("Cannot connect to server %s [%s]") % (server.host, error.msg)
if clues_pay(error.msg):
penalty = _PENALTY_PERM
else:
@@ -896,12 +892,12 @@ class Downloader(Thread):
block = True
elif error.code == 400:
# Temp connection problem?
errormsg = None
logging.debug("Unspecified error 400 from server %s", server.host)
penalty = _PENALTY_VERYSHORT
block = True
else:
# Unknown error, just keep trying
errormsg = T("Cannot connect to server %s [%s]") % (server.host, error.msg)
penalty = _PENALTY_UNKNOWN
block = True
+111 -14
View File
@@ -210,13 +210,39 @@ for i in range(1, 32):
CH_ILLEGAL_WIN += chr(i)
def sanitize_filename(filename: str) -> str:
def sanitize_filename(filename: str, allow_subdirs: bool = False) -> str:
"""Return filename with illegal chars converted to legal ones
and with the par2 extension always in lowercase
and with the par2 extension always in lowercase.
With allow_subdirs the forward slashes that par2 uses to separate sub-directories are kept
and every part is sanitized on its own. The result is always local to the current folder:
empty parts, "." and ".." are dropped, so a leading slash or any amount of traversal can
never produce a name that points outside of it. The admin folder is dropped as well, so
it can never point into it either.
"""
if not filename:
return filename
if allow_subdirs:
# Par2 always uses a forward slash, no matter which platform created the set
parts = []
for part in filename.split("/"):
# Sanitize first, the checks below run on the stripped name
part = sanitize_filename(part)
if part in ("", os.curdir):
continue
if part == os.pardir:
logging.info("Dropping directory traversal from name %s", filename)
continue
if part.lower() == JOB_ADMIN.lower():
# Never let a name point into the admin folder, its files are pickle-loaded
logging.info("Dropping admin folder from name %s", filename)
continue
parts.append(part)
# Nothing usable left, or no sub-directories after all
if not parts:
return "unknown"
return os.path.join(*parts)
filename = unicode_nfc_normalize(filename)
illegal = CH_ILLEGAL
@@ -455,6 +481,30 @@ def same_directory(a: str, b: str) -> int:
return is_subfolder
def points_into_admin_dir(path: str, base: str) -> bool:
"""Return True if path is, or is inside, an admin folder somewhere below base.
Both sides are resolved first, because the name alone cannot be trusted: on Windows
an NTFS 8.3 alias ("__ADMI~1") and on any platform a link point at a directory that
is named differently than the path says. Resolving also settles any case difference.
"""
# realpath() only keeps the \\?\ prefix if it was there to begin with, so clip both
# sides: comparing a prefixed path to a plain one would put the admin folder out of
# sight and let it pass
try:
relative = os.path.relpath(clip_path(os.path.realpath(path)), clip_path(os.path.realpath(base)))
except ValueError:
# Windows only: resolving ended up on another drive, so it left base altogether
return True
return JOB_ADMIN.lower() in relative.lower().split(os.sep)
def points_outside(root: str, path: str) -> bool:
"""Return True if the file at path does not end up inside root.
Both sides are resolved, so a root that is itself a link is fine, a link inside it is not.
"""
return same_directory(os.path.realpath(root), os.path.dirname(os.path.realpath(path))) == 0
def is_network_path(path: str) -> bool:
"""Check weither a path is a network path.
On Windows, use win32 functions to detect users that try to avoid this detection by using a mapped drive letter.
@@ -784,7 +834,7 @@ def get_unique_dir(path: str, n: int = 0, create_dir: bool = True) -> str | bool
if n:
new_path = "%s.%s" % (path, n)
if not os.path.exists(new_path):
if not os.path.lexists(new_path):
if create_dir:
return create_all_dirs(new_path, apply_permissions=True)
else:
@@ -801,7 +851,7 @@ def get_unique_filename(path: str) -> str:
num = 1
new_path, filename = os.path.split(path)
name, ext = os.path.splitext(filename)
while os.path.exists(path):
while os.path.lexists(path):
filename = "%s.%d%s" % (name, num, ext)
num += 1
path = os.path.join(new_path, filename)
@@ -821,8 +871,9 @@ def listdir_full(input_dir: str, recursive: bool = True) -> list[str]:
return filelist
def move_to_path(path: str, new_path: str) -> tuple[bool, Optional[str]]:
def move_to_path(path: str, new_path: str, root: Optional[str] = None) -> tuple[bool, Optional[str]]:
"""Move a file to a new path, optionally give unique filename
With root the destination has to resolve to a location inside it
Return (ok, new_path)
"""
ok = True
@@ -838,6 +889,11 @@ def move_to_path(path: str, new_path: str) -> tuple[bool, Optional[str]]:
new_path = get_unique_filename(new_path)
if new_path:
if root and points_outside(root, new_path):
logging.error(T("Failed moving %s to %s"), clip_path(path), clip_path(new_path))
logging.info("Refusing to move %s, it points outside %s", new_path, root)
return False, None
logging.debug("Moving (overwrite: %s) %s => %s", overwrite, path, new_path)
if not os.path.exists(new_path_dir):
create_all_dirs(os.path.dirname(new_path), apply_permissions=True)
@@ -922,13 +978,20 @@ def renamer(old: str, new: str, create_local_directories: bool = False) -> str:
if create_local_directories:
oldpath, _ = os.path.split(old)
# Check not outside directory
# In case of "same_file() == 1": same directory, so nothing to do
if same_directory(oldpath, path) == 0:
if points_outside(oldpath, new):
# Outside current directory, this is most likely malicious
logging.error(T("Blocked attempt to create directory %s"), path)
raise OSError("Refusing to go outside directory")
elif same_directory(oldpath, path) == 2:
# Sub-directory, so create if does not yet exist:
# Refuse the admin folder, it is pickle-loaded. Check the whole new path: if the
# last element is the admin folder itself, the move below would put the file
# inside it, since shutil.move accepts a directory as its target.
if points_into_admin_dir(new, oldpath):
logging.error(T("Blocked attempt to create directory %s"), path)
raise OSError("Refusing to go into admin directory")
if not os.path.isdir(path):
# Sub-directory, create if does not yet exist:
create_all_dirs(path)
logging.debug('Renaming "%s" to "%s"', old, new)
@@ -1124,6 +1187,44 @@ def get_new_id(prefix: str, folder: str, check_list: Optional[list] = None) -> s
raise IOError
# Allowlist of every global our pickles may reference: safe data types and our persisted classes.
# Explicit, not a "sabnzbd.*" wildcard, which would also admit gadget classes (e.g. a __del__
# that runs os.kill). sabnzbd.nzbstuff is the pre-refactor module path (compat shim).
_SAFE_GLOBALS = {
("datetime", "datetime"),
("datetime", "date"),
("datetime", "time"),
("datetime", "timedelta"),
("datetime", "timezone"),
("time", "struct_time"),
("os", "stat_result"),
("collections", "OrderedDict"),
("collections", "defaultdict"),
("collections", "deque"),
("builtins", "set"),
("builtins", "frozenset"),
("builtins", "bytearray"),
("builtins", "complex"),
("copyreg", "_reconstructor"),
("sabnzbd.nzb.object", "NzbObject"),
("sabnzbd.nzb.file", "NzbFile"),
("sabnzbd.nzb.article", "Article"),
("sabnzbd.par2file", "FilePar2Info"),
("sabnzbd.nzbstuff", "NzbObject"),
("sabnzbd.nzbstuff", "NzbFile"),
("sabnzbd.nzbstuff", "Article"),
}
class RestrictedUnpickler(pickle.Unpickler):
"""Unpickler restricted to an allowlist, so a hostile pickle cannot run code"""
def find_class(self, module, name):
if (module, name) in _SAFE_GLOBALS:
return super().find_class(module, name)
raise pickle.UnpicklingError("Refusing to unpickle %s.%s" % (module, name))
def save_data(data: Any, _id: str, path: str, do_pickle: bool = True, silent: bool = False):
"""Save data to a diskfile"""
if not silent:
@@ -1172,11 +1273,7 @@ def load_data(
try:
with open(path, "rb") as data_file:
if do_pickle:
try:
data = pickle.load(data_file, encoding=sabnzbd.encoding.CODEPAGE)
except UnicodeDecodeError:
# Could be Python 2 data that we can load using old encoding
data = pickle.load(data_file, encoding="latin1")
data = RestrictedUnpickler(data_file, encoding=sabnzbd.encoding.CODEPAGE).load()
elif mutable:
data = bytearray(os.fstat(data_file.fileno()).st_size)
data_file.readinto(data)
+70 -42
View File
@@ -30,6 +30,7 @@ import socket
import ssl
import functools
import copy
import secrets
from random import randint
from xml.sax.saxutils import escape
from Cheetah.Template import Template
@@ -105,11 +106,23 @@ _MSG_APIKEY_REQUIRED = "API Key Required"
_MSG_APIKEY_INCORRECT = "API Key Incorrect"
class SecureDispatcher(cherrypy.dispatch.Dispatcher):
"""Dispatcher that refuses paths traversing private attributes"""
def find_handler(self, path):
handler, vpath = super().find_handler(path)
# Punctuation is translated to underscores before the attribute lookup
if any(segment.translate(self.translate).startswith("_") for segment in path.split("/")):
return None, []
return handler, vpath
def secured_expose(
wrap_func: Optional[Callable] = None,
check_configlock: bool = False,
check_for_login: bool = True,
check_api_key: bool = False,
api_route: bool = False,
access_type: int = 4,
) -> Callable | str:
"""Wrapper for both cherrypy.expose and login/access check"""
@@ -119,14 +132,15 @@ def secured_expose(
check_configlock=check_configlock,
check_for_login=check_for_login,
check_api_key=check_api_key,
api_route=api_route,
access_type=access_type,
)
# Expose to cherrypy
wrap_func.exposed = True
@functools.wraps(wrap_func)
def internal_wrap(*args, **kwargs):
if len(args) > 1:
raise cherrypy.NotFound()
# Label for logging in this and other functions, handling X-Forwarded-For
# The cherrypy.request object allows adding custom attributes
if cherrypy.request.headers.get("X-Forwarded-For"):
@@ -182,7 +196,7 @@ def secured_expose(
# Some pages need correct API key
if check_api_key:
if msg := check_apikey(kwargs):
if msg := check_apikey(kwargs, api_route=api_route):
cherrypy.response.status = 403
if cfg.api_warnings():
return msg
@@ -191,6 +205,9 @@ def secured_expose(
# All good, cool!
return wrap_func(*args, **kwargs)
# Expose only the wrapper to cherrypy
del internal_wrap.__wrapped__
internal_wrap.exposed = True
return internal_wrap
@@ -262,8 +279,10 @@ def check_hostname():
return False
# Create a more unique ID for each instance
COOKIE_SECRET = str(randint(1000, 100000) * os.getpid())
# Per-process secret used to sign login cookies. Regenerated on every start,
# so sessions do not survive a restart (as before), but with enough entropy
# that a captured cookie cannot be brute-forced back to the secret.
COOKIE_SECRET = secrets.token_hex(32)
def remote_ip_from_xff(xff_ips: list[str]) -> str:
@@ -295,23 +314,25 @@ def set_login_cookie(remove=False, remember_me=False):
else:
remote_ip = cherrypy.request.remote.ip
cookie_str = utob(str(salt) + remote_ip + COOKIE_SECRET)
cherrypy.response.cookie["login_cookie"] = hashlib.sha1(cookie_str).hexdigest()
cherrypy.response.cookie["login_cookie"]["path"] = "/"
cherrypy.response.cookie["login_cookie"]["httponly"] = 1
cherrypy.response.cookie["login_salt"] = salt
cherrypy.response.cookie["login_salt"]["path"] = "/"
cherrypy.response.cookie["login_salt"]["httponly"] = 1
# If we want to be remembered
if remember_me:
cherrypy.response.cookie["login_cookie"]["max-age"] = 3600 * 24 * 14
cherrypy.response.cookie["login_salt"]["max-age"] = 3600 * 24 * 14
# To remove
if remove:
# Never emit a valid cookie/salt pair on logout
cherrypy.response.cookie["login_cookie"] = ""
cherrypy.response.cookie["login_salt"] = ""
cherrypy.response.cookie["login_cookie"]["expires"] = 0
cherrypy.response.cookie["login_salt"]["expires"] = 0
else:
cookie_str = utob(str(salt) + remote_ip + COOKIE_SECRET)
cherrypy.response.cookie["login_cookie"] = hashlib.sha1(cookie_str).hexdigest()
cherrypy.response.cookie["login_salt"] = salt
# If we want to be remembered
if remember_me:
cherrypy.response.cookie["login_cookie"]["max-age"] = 3600 * 24 * 14
cherrypy.response.cookie["login_salt"]["max-age"] = 3600 * 24 * 14
cherrypy.response.cookie["login_cookie"]["path"] = "/"
cherrypy.response.cookie["login_cookie"]["httponly"] = 1
cherrypy.response.cookie["login_salt"]["path"] = "/"
cherrypy.response.cookie["login_salt"]["httponly"] = 1
def check_login_cookie():
@@ -332,10 +353,14 @@ def check_login_cookie():
def check_login():
# Not when no authentication required or basic-auth is on
if not cfg.html_login() or not cfg.username() or not cfg.password():
# Not when no authentication required
if not cfg.username() or not cfg.password():
return True
# Basic-auth is checked by cherrypy, only on the routes where the tool is enabled
if not cfg.html_login():
return bool(cherrypy.request.login)
# If we show login for external IP, by using access_type=6 we can check if IP match
if cfg.inet_exposure() == 5 and check_access(access_type=6):
return True
@@ -369,31 +394,34 @@ def set_auth(conf):
conf.update({"tools.auth_basic.on": False})
def check_apikey(kwargs):
"""Check API-key or NZB-key
Return None when OK, otherwise an error message
"""
mode = kwargs.get("mode", "")
name = kwargs.get("name", "")
# Lookup required access level for the specific api-call
req_access = sabnzbd.api.api_level(mode, name)
if not check_access(req_access, warn_user=True):
return _MSG_ACCESS_DENIED
# Skip for auth and version calls
if mode in ("version", "auth"):
return None
# First check API-key, if OK that's sufficient
def check_apikey(kwargs, api_route: bool = False):
"""Check API-key or NZB-key, return None when OK, else an error message.
Only the real /api route trusts "mode"; elsewhere it's attacker-controlled,
so a valid API-key is always required."""
key = kwargs.get("apikey")
if api_route:
mode = kwargs.get("mode", "")
name = kwargs.get("name", "")
req_access = sabnzbd.api.api_level(mode, name)
if not check_access(req_access, warn_user=True):
return _MSG_ACCESS_DENIED
# Skip for auth and version calls
if mode in ("version", "auth"):
return None
# NZB-key suffices for nzb-level calls
if req_access == 1 and key and key == cfg.nzb_key():
return None
# A valid API-key is required for everything else
if not key:
log_warning_and_ip(
T("API Key missing, please enter the api key from Config->General into your 3rd party program:")
)
return _MSG_APIKEY_REQUIRED
elif req_access == 1 and key == cfg.nzb_key():
return None
elif key == cfg.api_key():
return None
else:
@@ -492,7 +520,7 @@ class MainPage:
sabnzbd.shutdown_program()
return T("SABnzbd shutdown finished")
@secured_expose(check_api_key=True, access_type=1)
@secured_expose(check_api_key=True, api_route=True, access_type=1)
def api(self, **kwargs):
"""Redirect to API-handler, we check the access_type in the API-handler"""
return api_handler(kwargs)
+3 -2
View File
@@ -20,6 +20,7 @@ sabnzbd.macosmenu - macOS Top Menu
"""
import os
import subprocess
import sys
import time
import logging
@@ -506,7 +507,7 @@ class SABnzbdDelegate(NSObject):
def openFolderAction_(self, sender):
folder2open = sender.representedObject()
os.system('open "%s"' % folder2open)
subprocess.run(["/usr/bin/open", folder2open])
def restartAction_(self, sender):
self.setMenuTitle_("\n\n%s\n" % (T("Stopping...")))
@@ -585,7 +586,7 @@ class SABnzbdDelegate(NSObject):
elif notification.activationType() == 2:
# User clicked on the action button
if os.path.exists(folder2open := notification.userInfo()["value"]):
os.system('open "%s"' % folder2open)
subprocess.run(["/usr/bin/open", folder2open])
# Remove this notification after interaction
DefaultUserNotificationCenter._removeDisplayedNotification_(notification)
+7 -1
View File
@@ -61,6 +61,7 @@ from sabnzbd.constants import (
DEF_ARTICLE_CACHE_MAX,
REPAIR_REQUEST,
GUESSIT_SORT_TYPES,
PP_LOOKUP,
)
import sabnzbd.config as config
import sabnzbd.cfg as cfg
@@ -244,7 +245,7 @@ def cat_pp_script_sanitizer(
cat: Optional[str] = None,
pp: Optional[int | str] = None,
script: Optional[str] = None,
) -> tuple[Optional[int | str], Optional[str], Optional[str]]:
) -> tuple[Optional[str], Optional[int], Optional[str]]:
"""Basic sanitizer from outside input to a bit more predictable values"""
# * and Default are valid values
if safe_lower(cat) in ("", "none"):
@@ -253,6 +254,11 @@ def cat_pp_script_sanitizer(
# Cannot use "not pp" because pp can also be 0
if safe_lower(pp) in ("", "-1", "none"):
pp = None
else:
# Only accept a valid pp value (key of PP_LOOKUP)
pp = int_conv(pp)
if pp not in PP_LOOKUP:
pp = None
# Check for valid script is performed in NzbObject init
if not script or safe_lower(script) == "default":
+21 -2
View File
@@ -70,6 +70,7 @@ from sabnzbd.filesystem import (
create_all_dirs,
UNWANTED_FILE_PERMISSIONS,
get_unique_filename,
points_outside,
)
from sabnzbd.nzb import NzbObject
import sabnzbd.cfg as cfg
@@ -454,7 +455,7 @@ def file_join(nzo: NzbObject, workdir_complete: str, joinables: list[str]) -> tu
joinable_sets[joinable_set].sort()
# If par2 already did the work, just remove the files
if os.path.exists(joinable_set):
if os.path.lexists(joinable_set):
logging.debug("file_join(): Skipping %s, (probably) joined by par2", joinable_set)
if nzo.delete:
clean_up_joinables(current)
@@ -472,6 +473,12 @@ def file_join(nzo: NzbObject, workdir_complete: str, joinables: list[str]) -> tu
filename = filename.replace(nzo.download_path, workdir_complete)
logging.debug("file_join(): Assembling %s", filename)
join_root = workdir_complete or nzo.download_path
if points_outside(join_root, filename):
raise OSError("Refusing to join into %s, it points outside %s" % (filename, join_root))
if os.path.islink(filename):
raise OSError("Refusing to join into %s, it is a link" % filename)
# Join the segments
with open(filename, "ab") as joined_file:
n = get_seq_number(current[0])
@@ -1107,6 +1114,9 @@ def tar_extract(nzo: NzbObject, tar_path: str, extraction_path: str, one_folder:
def tar_filter(member: tarfile.TarInfo, path: str) -> Optional[tarfile.TarInfo]:
"""Applies tarfile.data_filter, removes unwanted permissions and can prevent overwrites"""
if not member.isreg() and not member.isdir():
logging.info("Skipping %s from tar file, it is not a file or folder", member.name)
return None
member = tarfile.data_filter(member, path)
if member is not None and member.isreg():
member = member.replace(mode=member.mode & ~UNWANTED_FILE_PERMISSIONS)
@@ -1116,6 +1126,9 @@ def tar_extract(nzo: NzbObject, tar_path: str, extraction_path: str, one_folder:
member = member.replace(
name=os.path.relpath(get_unique_filename(os.path.join(path, member.name)), path)
)
if points_outside(path, os.path.join(path, member.name)):
logging.info("Skipping %s from tar file, it points outside %s", member.name, path)
return None
new_files.append(os.path.join(extraction_path, member.name))
return member
@@ -1867,7 +1880,13 @@ def sfv_check(sfvs: list[str], nzo: NzbObject) -> bool:
if calculated_crc32.get(nzf.filename, "") == sfv_parse_results[file]:
try:
logging.debug("SFV-check will rename %s to %s", nzf.filename, file)
renamer(os.path.join(nzo.download_path, nzf.filename), os.path.join(nzo.download_path, file))
# Untrusted sfv name: normalize separators and contain
file = os.path.normpath(file)
renamer(
os.path.join(nzo.download_path, nzf.filename),
os.path.join(nzo.download_path, file),
create_local_directories=True,
)
renames[file] = nzf.filename
nzf.filename = file
result &= True
+12 -1
View File
@@ -89,6 +89,8 @@ from sabnzbd.filesystem import (
remove_data,
get_ext,
create_work_name,
points_outside,
points_into_admin_dir,
RAR_RE,
)
from sabnzbd.par2file import FilePar2Info, has_par2_in_filename, analyse_par2, parse_par2_file, is_par2_file
@@ -1450,10 +1452,19 @@ class NzbObject(TryList):
candidate = filename
path = os.path.join(directory, candidate)
num = 1
while candidate in self.filenames or os.path.exists(path):
while candidate in self.filenames or os.path.lexists(path):
candidate = f"{base_name}.{num}{ext}"
path = os.path.join(directory, candidate)
num += 1
if subdir := os.path.dirname(candidate):
# sanitize_filename() keeps the name local, so this should never trigger
if points_outside(directory, path):
raise ValueError("Refusing to write %s outside of %s" % (candidate, directory))
if points_into_admin_dir(path, directory):
raise ValueError("Refusing to write %s into the admin folder" % candidate)
create_all_dirs(os.path.join(directory, subdir))
self.filenames.add(candidate)
return path
+1 -1
View File
@@ -515,7 +515,7 @@ def process_job(nzo: NzbObject) -> bool:
if JOB_ADMIN not in path:
new_path = path.replace(nzo.download_path, tmp_workdir_complete)
nzo.set_action_line(T("Moving"), get_filename(path))
ok, new_path = move_to_path(path, new_path)
ok, new_path = move_to_path(path, new_path, root=tmp_workdir_complete)
if new_path:
newfiles.append(new_path)
if not ok:
+40 -15
View File
@@ -621,10 +621,10 @@ class RSSRepository:
"""
Expire G/B links that are not in new_jobs (mark them 'X')
Expired links older than 3 days are removed
Expired links older than 7 days are removed
"""
now = datetime.datetime.now(datetime.timezone.utc)
limit = int((now - datetime.timedelta(days=3)).timestamp())
limit = int((now - datetime.timedelta(days=7)).timestamp())
if new_urls:
# Create temporary table for all new URLs
@@ -635,6 +635,22 @@ class RSSRepository:
placeholders = ",".join(["(?)"] * len(batch))
self.db.execute(f"INSERT INTO temp_urls(url) VALUES {placeholders}", batch)
# Refresh seen_at for everything still listed in the feed. Entries in a terminal
# state are skipped during evaluation, so this is the only place they are touched
# and without it they would be purged while still present in the feed.
self.db.execute(
"""
UPDATE rss
SET seen_at = ?
WHERE feed = ?
AND url IN (SELECT url FROM temp_urls)
""",
(
int(datetime.datetime.now(datetime.timezone.utc).timestamp()),
feed,
),
)
# Update rss to mark G/B not in temp_urls as X
self.db.execute(
"""
@@ -686,6 +702,7 @@ class RSSRepository:
def get_feed_jobs(
self,
feed: Optional[str] = None,
archive: Optional[bool] = None,
search: Optional[str] = None,
states: Optional[list[RSSState]] = None,
) -> Generator[ResolvedEntry, Any, None]:
@@ -693,6 +710,11 @@ class RSSRepository:
command_args = []
where_clauses = []
if archive:
where_clauses.append("archived_at IS NOT NULL")
else:
where_clauses.append("archived_at IS NULL")
if search is not None:
where_clauses.append("title LIKE ?")
command_args.append(convert_search(search))
@@ -847,10 +869,13 @@ class RSSRepository:
)
return bool(self.db.cursor.fetchone()["found"])
def expired_purge(self):
"""Removed expired links from all feeds"""
def purge_removed_feeds(self):
"""Remove all records of feeds that are no longer configured"""
configured = set(config.get_rss())
for feed in self.get_feeds():
self.remove_obsolete(feed)
if feed not in configured:
logging.debug("Purging records of removed feed %s", feed)
self.clear_feed(feed)
def import_rss_records(self):
"""Migrate old RSS database"""
@@ -964,7 +989,8 @@ class RSSReader:
if readout:
gen = self.fetch_rss(feed, uris)
else:
gen = repo.get_feed_jobs(feed=feed)
# Materialize before evaluating: each write needs the read cursor closed
gen = list(repo.get_feed_jobs(feed=feed))
# Evaluate rules and apply side effects
try:
@@ -981,7 +1007,6 @@ class RSSReader:
new_links.add(entry.link)
downloaded = self._process_entry(
repo,
feed_entry=entry,
filters=filters,
first=first,
@@ -998,7 +1023,8 @@ class RSSReader:
if new_downloads and cfg.email_rss() and not force:
emailer.rss_mail(feed, new_downloads)
repo.remove_obsolete(feed, new_links, purge_downloaded=True)
if readout:
repo.remove_obsolete(feed, new_links, purge_downloaded=True)
return ""
@@ -1119,8 +1145,6 @@ class RSSReader:
def _process_entry(
self,
repo: RSSRepository,
*,
feed_entry: ResolvedEntry,
filters: FeedConfig,
first: bool,
@@ -1178,8 +1202,9 @@ class RSSReader:
initial_scan=initial_scan,
)
repo.upsert(resolved_entry)
self.enqueue_download(repo, resolved_entry)
with sabnzbd.rss.rss_repository() as repo:
repo.upsert(resolved_entry)
self.enqueue_download(repo, resolved_entry)
return bool(evaluation.matched and should_download)
@@ -1266,10 +1291,10 @@ def special_rss_site(url: str) -> bool:
return bool(cfg.rss_filenames() or match_str(url, cfg.rss_odd_titles()))
def expired_purge():
"""Purge links older than 3 days"""
def purge_removed_feeds():
"""Purge records of feeds that are no longer configured"""
with rss_repository() as repo:
repo.expired_purge()
repo.purge_removed_feeds()
@contextmanager
+3
View File
@@ -822,6 +822,9 @@ SKIN_TEXT = {
"Glitter-freeTemp": TT("Temp Folder"),
"Glitter-search": TT("Search"),
"Glitter-clearSearch": TT("Clear search"),
"Glitter-searchExplain": TT(
"Search by name, or refine with filters <code>cat:</code>, <code>status:</code> and <code>priority:</code> (Queue). For example: <code>my show cat:tv</code>"
), #: Explains the queue/history search box, shown as a tooltip
"Glitter-multiOperations": TT("Multi-Operations"),
"Glitter-multiSelect": TT("Hold shift key to select a range"),
"Glitter-selectJob": TT("Select job"),
+1 -1
View File
@@ -648,7 +648,7 @@ def move_to_parent_directory(workdir: str, files: list[str]) -> tuple[str, bool,
# Not inside the directory we move, keep as-is
moved_files.append(path)
continue
ok, new_path = move_to_path(path, path.replace(workdir, dest))
ok, new_path = move_to_path(path, path.replace(workdir, dest), root=dest)
if not ok:
return dest, False, moved_files + files[nr:]
if new_path:
+1 -1
View File
@@ -6,5 +6,5 @@
# You MUST use double quotes (so " and not ')
# Do not forget to update the appdata file for every major release!
__version__ = "5.1.0RC2"
__version__ = "5.1.3"
__baseline__ = "unknown"
Binary file not shown.
+323 -1
View File
@@ -19,10 +19,14 @@
tests.test_filesystem - Testing functions in filesystem.py
"""
import datetime
import io
import pickle
import stat
import sys
import os
import shutil
import time
import unicodedata
from pathlib import Path
import tempfile
@@ -38,7 +42,7 @@ import sabnzbd
import sabnzbd.cfg
from sabnzbd import cfg
import sabnzbd.filesystem as filesystem
from sabnzbd.constants import DEF_FOLDER_MAX, DEF_FILE_MAX
from sabnzbd.constants import DEF_FOLDER_MAX, DEF_FILE_MAX, JOB_ADMIN
# Set the global uid for fake filesystems to a non-root user;
# by default this depends on the user running pytest.
@@ -123,6 +127,88 @@ class TestFileFolderNameSanitizer:
assert filesystem.sanitize_filename("../") == ".._"
assert filesystem.sanitize_filename("../test") == ".._test"
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
@pytest.mark.platform()
def test_file_allow_subdirs(self, platform):
"""Par2 uses "/" to separate sub-directories, no matter which platform created the set"""
assert filesystem.sanitize_filename("sub/test.rar", allow_subdirs=True) == os.path.join("sub", "test.rar")
assert filesystem.sanitize_filename("sub/deeper/test.rar", allow_subdirs=True) == os.path.join(
"sub", "deeper", "test.rar"
)
# No sub-directory at all, or nothing but separators
assert filesystem.sanitize_filename("test.rar", allow_subdirs=True) == "test.rar"
assert filesystem.sanitize_filename("a//b.rar", allow_subdirs=True) == os.path.join("a", "b.rar")
assert filesystem.sanitize_filename("sub/./test.rar", allow_subdirs=True) == os.path.join("sub", "test.rar")
# Every part is sanitized on its own, chr(0) is illegal on all platforms
assert filesystem.sanitize_filename("sub" + chr(0) + "1/test" + chr(0) + "2.rar", allow_subdirs=True) == (
os.path.join("sub_1", "test_2.rar")
)
@pytest.mark.parametrize(
"hostile_name",
[
"/test.rar",
"//test.rar",
"../test.rar",
"../../../../../../etc/shadow",
"sub/../../test.rar",
"sub/../../../sub/test.rar",
"./../test.rar",
"../..",
"../",
"/",
"//",
"/../",
"...",
"....",
# Whitespace must not hide a part from the checks, it is stripped while sanitizing
" .. /test.rar",
" .. / .. /etc/shadow",
"sub/ .. / .. /test.rar",
"\t..\t/test.rar",
"\xa0../test.rar",
" .. ",
],
)
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
@pytest.mark.platform()
def test_file_allow_subdirs_cannot_escape(self, platform, hostile_name):
"""Whatever the par2 claims, the result has to stay inside the folder it is used in.
Joining it onto any base directory must never point above that base."""
result = filesystem.sanitize_filename(hostile_name, allow_subdirs=True)
assert result, "an empty result would resolve to the base directory itself"
assert not os.path.isabs(result)
assert os.pardir not in result.split(os.sep)
# The real test: it cannot climb out of whatever it gets joined to
base = os.path.join(os.sep + "downloads", "incomplete", "job")
resolved = os.path.normpath(os.path.join(base, result))
assert resolved.startswith(base + os.sep), "%s escaped to %s" % (hostile_name, resolved)
@pytest.mark.parametrize(
"hostile_name",
[
JOB_ADMIN,
JOB_ADMIN + "/__verified__",
JOB_ADMIN.lower() + "/__verified__",
"sub/" + JOB_ADMIN + "/__verified__",
JOB_ADMIN + "/deeper/__verified__",
# Whitespace must not hide a part from the checks, it is stripped while sanitizing
" " + JOB_ADMIN + " /__verified__",
"\t" + JOB_ADMIN + "\t/__verified__",
"sub/ " + JOB_ADMIN.lower() + " /__verified__",
],
)
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
@pytest.mark.platform()
def test_file_allow_subdirs_cannot_enter_admin(self, platform, hostile_name):
"""The admin folder is pickle-loaded, so a par2 name must never point into it"""
result = filesystem.sanitize_filename(hostile_name, allow_subdirs=True)
assert result, "an empty result would resolve to the base directory itself"
assert JOB_ADMIN.lower() not in result.lower().split(os.sep)
@pytest.mark.platform("linux")
def test_folder_illegal_chars_linux(self):
assert filesystem.sanitize_foldername('test"aftertest') == "test_aftertest"
@@ -443,6 +529,131 @@ class TestSameDirectory:
assert 0 == filesystem.same_directory("/test/../Home", "/home")
class TestPointsIntoAdminDir:
def test_by_name(self, tmp_path):
base = str(tmp_path)
assert filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN), base)
assert filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN, "__verified__"), base)
assert filesystem.points_into_admin_dir(os.path.join(base, "sub", JOB_ADMIN, "__verified__"), base)
def test_regular_names_are_left_alone(self, tmp_path):
base = str(tmp_path)
assert not filesystem.points_into_admin_dir(os.path.join(base, "testfile.rar"), base)
assert not filesystem.points_into_admin_dir(os.path.join(base, "sub", "testfile.rar"), base)
# Only a full part counts, not a name that merely starts with it
assert not filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN + "-data", "testfile.rar"), base)
def test_link_cannot_hide_it(self, tmp_path):
"""On Windows an NTFS 8.3 alias ("__ADMI~1") points at the admin folder under a
different name, exactly like a link does here, so the name cannot be trusted"""
base = str(tmp_path)
admin_dir = os.path.join(base, JOB_ADMIN)
os.mkdir(admin_dir)
linkname = os.path.join(base, "notadmin")
os.symlink(admin_dir, linkname)
assert filesystem.points_into_admin_dir(linkname, base)
assert filesystem.points_into_admin_dir(os.path.join(linkname, "__verified__"), base)
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="NTFS 8.3 aliases only exist on Windows")
def test_ntfs_8dot3_alias_cannot_hide_it(self, tmp_path):
"""The real thing the link above stands in for: NTFS keeps an 8.3 alias for every
long name, so "__ADMI~1" reaches the admin folder without ever spelling it out"""
import win32api
base = str(tmp_path)
admin_dir = os.path.join(base, JOB_ADMIN)
os.mkdir(admin_dir)
# Ask the filesystem for the alias instead of assuming what it generated
alias = os.path.basename(win32api.GetShortPathName(admin_dir))
if alias.lower() == JOB_ADMIN.lower():
pytest.skip("8.3 name creation is disabled on this volume")
assert filesystem.points_into_admin_dir(os.path.join(base, alias), base)
assert filesystem.points_into_admin_dir(os.path.join(base, alias, "__verified__"), base)
# And the rename that the alias was meant to sneak through has to fail
filename = os.path.join(base, "myfile.txt")
Path(filename).touch()
with pytest.raises(OSError):
filesystem.renamer(filename, os.path.join(base, alias, "__verified__"), create_local_directories=True)
assert os.path.isfile(filename)
assert not os.listdir(admin_dir)
class TestPointsOutside:
def test_inside(self, tmp_path):
base = str(tmp_path)
assert not filesystem.points_outside(base, os.path.join(base, "file.bin"))
assert not filesystem.points_outside(base, os.path.join(base, "sub", "file.bin"))
def test_outside(self, tmp_path):
base = str(tmp_path)
assert filesystem.points_outside(base, os.path.join(base, os.pardir, "file.bin"))
assert filesystem.points_outside(base, os.path.join(base, "sub", os.pardir, os.pardir, "file.bin"))
def test_root_reached_through_a_link_is_fine(self, tmp_path):
"""The download and complete folder are allowed to be a link"""
base = str(tmp_path)
real = os.path.join(base, "real")
os.mkdir(real)
link = os.path.join(base, "link")
os.symlink(real, link)
assert not filesystem.points_outside(link, os.path.join(link, "file.bin"))
assert not filesystem.points_outside(link, os.path.join(real, "file.bin"))
assert not filesystem.points_outside(real, os.path.join(link, "file.bin"))
assert filesystem.points_outside(link, os.path.join(link, os.pardir, "file.bin"))
@pytest.mark.skipif(
sys.platform.startswith("win"), reason="Windows collapses '..' before the filesystem resolves links"
)
def test_link_inside_the_root_cannot_redirect(self, tmp_path):
base = str(tmp_path)
root = os.path.join(base, "root")
os.mkdir(root)
os.symlink(base, os.path.join(root, "up"))
os.symlink(".", os.path.join(root, "pivot"))
assert filesystem.points_outside(root, os.path.join(root, "up", "file.bin"))
assert filesystem.points_outside(root, os.path.join(root, "pivot", os.pardir, "file.bin"))
assert not filesystem.points_outside(root, os.path.join(root, "pivot", "file.bin"))
class TestMoveToPath:
def test_link_in_a_parent_cannot_redirect(self, tmp_path):
"""A linked directory in the path redirects the move just like a linked leaf"""
base = str(tmp_path)
root = os.path.join(base, "complete")
outside = os.path.join(base, "outside")
os.makedirs(root)
os.makedirs(outside)
os.symlink(outside, os.path.join(root, "sub"))
source = os.path.join(base, "source.bin")
Path(source).touch()
ok, new_path = filesystem.move_to_path(source, os.path.join(root, "sub", "moved.bin"), root=root)
assert not ok
assert not new_path
assert os.path.isfile(source)
assert not os.listdir(outside)
def test_move_inside_the_root_still_works(self, tmp_path):
base = str(tmp_path)
root = os.path.join(base, "complete")
os.makedirs(root)
source = os.path.join(base, "source.bin")
Path(source).touch()
ok, new_path = filesystem.move_to_path(source, os.path.join(root, "sub", "moved.bin"), root=root)
assert ok
assert os.path.isfile(new_path)
assert not os.path.isfile(source)
class TestFirstExistingPath:
def test_existing_path(self, tmp_path):
assert filesystem.first_existing_path(str(tmp_path)) == str(tmp_path)
@@ -874,6 +1085,16 @@ class TestGetUniqueDirFilename:
fake_fs.create_file(first_filename)
assert filesystem.get_unique_filename(test_file) == "/some/filename.2"
def test_dangling_link_is_taken(self, tmp_path):
"""A link whose target is missing still occupies the name, and handing it out would
write through it to wherever it points"""
base = str(tmp_path)
test_file = os.path.join(base, "file.name")
Path(test_file).touch()
os.symlink(os.path.join(base, "does_not_exist"), os.path.join(base, "file.1.name"))
assert filesystem.get_unique_filename(test_file) == os.path.join(base, "file.2.name")
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows specific tests")
class TestGetUniqueDirFilenameWin:
@@ -1203,9 +1424,110 @@ class TestRenamer:
assert os.path.isfile(filename)
assert not os.path.isfile(newfilename)
# ... renaming into the admin folder is not allowed either
admin_dir = os.path.join(dirname, JOB_ADMIN)
os.mkdir(admin_dir)
Path(filename).touch()
newfilename = os.path.join(admin_dir, "__verified__")
try:
filesystem.renamer(filename, newfilename, create_local_directories=True)
except Exception:
pass
assert os.path.isfile(filename)
assert not os.path.isfile(newfilename)
# ... nor is naming the admin folder itself: a move into an existing directory
# keeps the old basename, so this would end up inside the admin folder as well
Path(filename).touch()
try:
filesystem.renamer(filename, admin_dir, create_local_directories=True)
except Exception:
pass
assert os.path.isfile(filename)
assert not os.listdir(admin_dir)
# ... and not under another name that resolves to it, such as a link. On Windows
# an NTFS 8.3 alias ("__ADMI~1") reaches the admin folder the very same way.
linkname = os.path.join(dirname, "notadmin")
os.symlink(admin_dir, linkname)
Path(filename).touch()
try:
filesystem.renamer(filename, os.path.join(linkname, "__verified__"), create_local_directories=True)
except Exception:
pass
assert os.path.isfile(filename)
assert not os.listdir(admin_dir)
os.remove(linkname)
# Cleanup working directory
shutil.rmtree(dirname)
@pytest.mark.skipif(
sys.platform.startswith("win"), reason="Windows collapses '..' before the filesystem resolves links"
)
def test_link_cannot_redirect_rename(self, tmp_path):
"""The filesystem resolves a link before it handles "..", so "pivot/.." lands one
level higher than normalizing the path on its own suggests"""
base = str(tmp_path)
dirname = os.path.join(base, "job")
os.mkdir(dirname)
os.symlink(".", os.path.join(dirname, "pivot"))
filename = os.path.join(dirname, "myfile.txt")
Path(filename).touch()
escaped = os.path.join(base, "escaped.bin")
with pytest.raises(OSError):
filesystem.renamer(
filename, os.path.join(dirname, "pivot", "..", "escaped.bin"), create_local_directories=True
)
assert os.path.isfile(filename)
assert not os.path.exists(escaped)
# A link that leaves the directory outright is no stepping stone either
os.symlink(base, os.path.join(dirname, "outside"))
with pytest.raises(OSError):
filesystem.renamer(filename, os.path.join(dirname, "outside", "escaped.bin"), create_local_directories=True)
assert os.path.isfile(filename)
assert not os.path.exists(escaped)
class TestRestrictedUnpickler:
def test_round_trip(self, tmp_path):
data = {"a": 1, "s": {1, 2}, "when": datetime.datetime(2024, 1, 1), "t": time.gmtime(0), "st": os.stat(".")}
filesystem.save_data(data, "d", str(tmp_path))
assert filesystem.load_data("d", str(tmp_path), remove=False) == data
def test_rejects_code_execution_gadget(self):
class Evil:
def __reduce__(self):
return (os.system, ("echo pwned",))
with pytest.raises(pickle.UnpicklingError):
filesystem.RestrictedUnpickler(io.BytesIO(pickle.dumps(Evil()))).load()
def test_rejects_non_allowlisted_sabnzbd_class(self):
# kronos.ForkedScheduler has a __del__ that runs os.kill; referenced by name, rejected pre-import
def named_global(module, name):
return (
b"\x80\x04\x8c"
+ bytes([len(module)])
+ module.encode()
+ b"\x8c"
+ bytes([len(name)])
+ name.encode()
+ b"\x93."
)
with pytest.raises(pickle.UnpicklingError):
filesystem.RestrictedUnpickler(io.BytesIO(named_global("sabnzbd.utils.kronos", "ForkedScheduler"))).load()
def test_loads_legacy_3_0_rss_pickle(self):
path = os.path.join(SAB_DATA_DIR, "test_3_0_0_data_format")
data = filesystem.load_data("rss_data.sab", path, remove=False)
assert isinstance(data, dict) and data
feed_jobs = next(iter(data.values()))
assert isinstance(feed_jobs, dict) and feed_jobs
class TestUnwantedExtensions:
# Only test lowercase extensions without a leading dot: the unwanted_extensions
+36
View File
@@ -29,6 +29,7 @@ from random import choice, randint, sample
from warnings import warn
import pytest
import requests
from tavern.core import run
import sabnzbd
@@ -327,6 +328,41 @@ class TestOtherApi(ApiTestFunctions):
# Restore language setting to default
assert self._get_api_json("set_config_default", extra_args={"keyword": "language"})["status"] is True
@pytest.mark.parametrize("path", ["__wrapped__", "__wrapped__/x"])
def test_api_wrapped_not_dispatchable(self, path):
"""The unprotected handler below secured_expose must not be reachable via the URL"""
response = requests.get(
"http://%s:%s/api/%s" % (SAB_HOST, SAB_PORT, path),
params={
"self": "x",
"mode": "get_config",
"section": "misc",
"keyword": "download_dir",
"output": "json",
},
)
assert response.status_code == 404
assert "download_dir" not in response.text
@pytest.mark.parametrize(
"path",
[
# The handler is a bound method, so __self__ leads back into the page tree
"__self__/",
"__self__/config/",
"__self__/config/general/",
"__self__/scriptlog",
# Punctuation is translated to underscores before the attribute lookup
"--self--/config/",
"..self../config/",
],
)
def test_api_page_tree_not_dispatchable(self, path):
"""The pages behind the handler must not be reachable through the /api route"""
response = requests.get("http://%s:%s/api/%s" % (SAB_HOST, SAB_PORT, path))
assert response.status_code == 404
assert SAB_APIKEY not in response.text
def test_api_get_clear_warnings(self):
# Trigger warnings by sending requests with a truncated apikey
for _ in range(0, 2):
+126
View File
@@ -228,3 +228,129 @@ class TestInterfaceFunctions:
assert interface.remote_ip_from_xff(xff_ips) is expected_result
_func()
@pytest.mark.config({"api_key": "the_real_api_key", "nzb_key": "the_real_nzb_key"})
@pytest.mark.parametrize(
"api_route, kwargs, expected",
[
# /api route: version/auth public, NZB-key valid for nzb-level calls
(True, {"mode": "version"}, None),
(True, {"mode": "auth"}, None),
(True, {"mode": "addfile", "apikey": "the_real_nzb_key"}, None),
(True, {"mode": "queue", "apikey": "the_real_api_key"}, None),
(True, {"mode": "queue"}, interface._MSG_APIKEY_REQUIRED),
(True, {"mode": "queue", "apikey": "wrong"}, interface._MSG_APIKEY_INCORRECT),
# Web-ui routes must ignore 'mode': no version/auth or NZB-key bypass
(False, {"mode": "version"}, interface._MSG_APIKEY_REQUIRED),
(False, {"mode": "auth"}, interface._MSG_APIKEY_REQUIRED),
(False, {"mode": "addfile", "apikey": "the_real_nzb_key"}, interface._MSG_APIKEY_INCORRECT),
(False, {"apikey": "the_real_api_key"}, None),
(False, {"mode": "version", "apikey": "the_real_api_key"}, None),
(False, {"apikey": "wrong"}, interface._MSG_APIKEY_INCORRECT),
(False, {}, interface._MSG_APIKEY_REQUIRED),
],
)
def test_check_apikey_ignores_mode_off_api_route(self, api_route, kwargs, expected):
"""'mode' is only trusted on the real /api route, not on web-ui handlers."""
cherrypy.request.remote.ip = "127.0.0.1"
cherrypy.request.headers.update({"X-Forwarded-For": None})
cherrypy.request.remote_label = "127.0.0.1 [test]"
assert interface.check_apikey(kwargs, api_route=api_route) == expected
def test_secured_expose_hides_wrapped_function(self):
"""The unprotected function must not be reachable below the wrapper"""
class DummyPage:
@interface.secured_expose(check_api_key=True, api_route=True, access_type=1)
def api(self, **kwargs):
return "protected handler executed"
assert not hasattr(DummyPage.api, "__wrapped__")
assert not any(getattr(value, "exposed", False) for value in vars(DummyPage.api).values())
assert not hasattr(interface.MainPage.api, "__wrapped__")
@pytest.mark.parametrize(
"path, should_resolve",
[
("/", True),
("/api", True),
("/config/", True),
("/config/general/", True),
# Handlers are bound methods, so __self__ leads back into the page tree
("/api/__self__/", False),
("/api/__self__/config/", False),
("/api/__self__/config/general/", False),
("/config/general/__self__/", False),
# Punctuation is translated to underscores before the attribute lookup
("/api/--self--/", False),
("/api/..self../", False),
("/api/__func__/", False),
("/api/__wrapped__", False),
],
)
def test_dispatcher_refuses_private_attributes(self, path, should_resolve):
"""A private attribute is no route, it reaches a handler while the access rules
of another route are applied to the request"""
class GeneralPage:
@interface.secured_expose
def index(self, **kwargs):
return "general config page"
class ConfigPage:
def __init__(self):
self.general = GeneralPage()
@interface.secured_expose
def index(self, **kwargs):
return "config page"
class RootPage:
def __init__(self):
self.config = ConfigPage()
@interface.secured_expose
def index(self, **kwargs):
return "main page"
@interface.secured_expose(check_api_key=True, api_route=True, access_type=1)
def api(self, **kwargs):
return "api handler executed"
cherrypy.serving.request.app = cherrypy.Application(RootPage(), "/")
handler, _vpath = interface.SecureDispatcher().find_handler(path)
assert bool(handler) is should_resolve
@pytest.mark.config({"username": "sabuser", "password": "sabpass", "html_login": 0})
@pytest.mark.parametrize("login, expected", [(None, False), ("", False), ("sabuser", True)])
def test_check_login_verifies_basic_auth(self, login, expected):
"""Basic-auth is checked by cherrypy, which does not run on every route, so the
login it sets on the request has to be there"""
cherrypy.serving.request.login = login
assert interface.check_login() is expected
@pytest.mark.config({"verify_xff_header": False})
def test_logout_does_not_leak_valid_cookie(self):
"""A logout must never emit a cookie/salt pair that passes check_login_cookie.
The Set-Cookie header is readable regardless of its expiry, so leaking valid
values there is an authentication bypass (harvest via logout, then replay)."""
cherrypy.request.remote.ip = "10.11.12.13"
cherrypy.request.headers.update({"X-Forwarded-For": None})
# Sanity check: a real login produces a cookie that validates
cherrypy.response.cookie.clear()
interface.set_login_cookie()
cherrypy.request.cookie["login_cookie"] = cherrypy.response.cookie["login_cookie"].value
cherrypy.request.cookie["login_salt"] = cherrypy.response.cookie["login_salt"].value
assert interface.check_login_cookie() is True
# Logout must blank out the values, not emit a working hash/salt
cherrypy.response.cookie.clear()
interface.set_login_cookie(remove=True)
assert cherrypy.response.cookie["login_cookie"].value == ""
assert cherrypy.response.cookie["login_salt"].value == ""
# Replaying whatever the logout response carried must fail authentication
cherrypy.request.cookie["login_cookie"] = cherrypy.response.cookie["login_cookie"].value
cherrypy.request.cookie["login_salt"] = cherrypy.response.cookie["login_salt"].value
assert interface.check_login_cookie() is False
+13 -3
View File
@@ -109,16 +109,26 @@ class TestMisc:
(None, None, None, (None, None, None)),
("", "", "", (None, None, None)),
("none", "-1", "default", (None, None, None)),
("SomeCategory", "5", "SomeScript", ("SomeCategory", "5", "SomeScript")),
("SomeCategory", "1", "SomeScript", ("SomeCategory", 1, "SomeScript")),
("none", 0, "default", (None, 0, None)),
("Movies", "", "default", ("Movies", None, None)),
("", "10", "default", (None, "10", None)),
("none", "15", "", (None, "15", None)),
("none", 0, "Default", (None, 0, None)),
("other", "-1", "Default", ("other", None, None)),
("none", "None", "default", (None, None, None)),
("some", "none", "script", ("some", None, "script")),
("none", "NONE", "Default", (None, None, None)),
# pp must be a PP_LOOKUP key or None
("none", "2", "default", (None, 2, None)),
("none", 3, "default", (None, 3, None)),
# Out-of-range ints are invalid
("", "10", "default", (None, None, None)),
("none", "15", "", (None, None, None)),
("none", 4, "default", (None, None, None)),
# Non-numeric never passes as a string
("none", "-c", "default", (None, 0, None)),
("none", "echo pwned", "default", (None, 0, None)),
("none", "2; rm -rf /", "default", (None, 0, None)),
("none", "1.5", "default", (None, 0, None)),
],
)
def test_cat_pp_script_sanitizer(self, cat, pp, script, expected):
+180 -2
View File
@@ -51,8 +51,94 @@ class TestNewsUnpackFunctions:
assert not newsunpack.is_sfv_file("tests/data/only_comments.sfv")
assert not newsunpack.is_sfv_file("tests/data/random.bin")
def test_is_sevenfile(self):
# False, because the command is not set
def test_file_join_refuses_a_link_as_target(self, tmp_path):
"""A dangling link left behind by another extractor is not a free name, so joining
must not write the result through it"""
base = str(tmp_path)
download_path = os.path.join(base, "job")
workdir_complete = os.path.join(base, "complete")
os.mkdir(download_path)
os.mkdir(workdir_complete)
outside = os.path.join(base, "outside.bin")
joinables = []
for num in (1, 2):
joinable = os.path.join(download_path, "victim.%03d" % num)
with open(joinable, "wb") as part:
part.write(b"part%d" % num)
joinables.append(joinable)
# The link sits where the joined file is written, not next to the parts
os.symlink(os.path.join("..", "outside.bin"), os.path.join(workdir_complete, "victim"))
nzo = mock.Mock()
nzo.download_path = download_path
nzo.final_name = "test"
nzo.delete = False
nzo.set_action_line = mock.Mock()
nzo.set_unpack_info = mock.Mock()
failed, newfiles = newsunpack.file_join(nzo, workdir_complete, joinables)
assert not os.path.exists(outside), "written through the link to %s" % outside
assert failed
assert not newfiles
def test_file_join_refuses_a_link_in_a_parent(self, tmp_path):
"""A linked directory on the way to the joined name redirects the append"""
base = str(tmp_path)
download_path = os.path.join(base, "job")
workdir_complete = os.path.join(base, "complete")
outside = os.path.join(base, "outside")
os.makedirs(os.path.join(download_path, "sub"))
os.makedirs(workdir_complete)
os.makedirs(outside)
os.symlink(outside, os.path.join(workdir_complete, "sub"))
joinables = []
for num in (1, 2):
joinable = os.path.join(download_path, "sub", "victim.%03d" % num)
with open(joinable, "wb") as part:
part.write(b"part%d" % num)
joinables.append(joinable)
nzo = mock.Mock()
nzo.download_path = download_path
nzo.final_name = "test"
nzo.delete = False
nzo.set_action_line = mock.Mock()
nzo.set_unpack_info = mock.Mock()
failed, newfiles = newsunpack.file_join(nzo, workdir_complete, joinables)
assert not os.listdir(outside), "joined through the link into %s" % outside
assert failed
assert not newfiles
def test_sfv_check_blocks_path_traversal(self, tmp_path):
"""A traversing SFV filename must not move a file out of the job directory"""
download_path = str(tmp_path)
obfuscated_name = "6f1ed002ab5595859014ebf0951522d9"
obfuscated_path = os.path.join(download_path, obfuscated_name)
with open(obfuscated_path, "wb") as test_file:
test_file.write(b"payload")
# SFV entry with matching crc32 but a traversing target name
sfv_path = os.path.join(download_path, "check.sfv")
with open(sfv_path, "w") as sfv_file:
sfv_file.write("../escaped.bin deadbeef\n")
nzf = mock.Mock(filename=obfuscated_name, filepath=obfuscated_path, crc32=0xDEADBEEF)
nzo = mock.Mock(download_path=download_path, finished_files=[nzf])
assert newsunpack.sfv_check([sfv_path], nzo) is False
assert not os.path.exists(os.path.join(download_path, os.pardir, "escaped.bin"))
assert os.path.exists(obfuscated_path)
def test_is_sevenfile(self, monkeypatch):
# False, because the command is not set. Force it explicitly: SEVENZIP_COMMAND
# is a module global that another test in this class may have populated via
# find_programs(), and under pytest-xdist tests share no ordering guarantee.
monkeypatch.setattr(newsunpack, "SEVENZIP_COMMAND", None)
assert not newsunpack.SEVENZIP_COMMAND
assert not newsunpack.is_sevenfile("tests/data/test_7zip/testfile.7z")
@@ -65,6 +151,7 @@ class TestNewsUnpackFunctions:
assert newsunpack.is_sevenfile("tests/data/test_7zip/testfile.7z")
def test_sevenzip(self):
newsunpack.find_programs(".")
testzip = newsunpack.SevenZip("tests/data/test_7zip/testfile.7z")
assert testzip.namelist() == ["My_Test_Download.bin"]
# Basic check that we can get data from the 7zip
@@ -844,6 +931,97 @@ class TestTarUnpack:
assert error_code == 1, "TAR extraction should fail"
assert not extracted_files
def test_link_members_skipped_tar_unpack(self, tmp_path):
"""A download never needs links, and even one that stays inside the folder is enough
to redirect a later rename out of it, so they are dropped instead of extracted"""
tar_path = tmp_path / "links.tar"
with tarfile.open(tar_path, "w") as tar:
info = tarfile.TarInfo("file.txt")
info.size = 4
tar.addfile(info, io.BytesIO(b"test"))
# tarfile.data_filter() allows this one: the target stays inside the folder
pivot = tarfile.TarInfo("pivot")
pivot.type = tarfile.SYMTYPE
pivot.linkname = "."
tar.addfile(pivot)
hardlink = tarfile.TarInfo("hardlink.txt")
hardlink.type = tarfile.LNKTYPE
hardlink.linkname = "file.txt"
tar.addfile(hardlink)
tar_files = ["links.tar"]
expected_files = {"file.txt"}
error_code, extracted_files, complete_contents, download_contents, _nzo, temp_complete_dir = (
self._run_tar_unpack(str(tmp_path), tar_files)
)
self._assert_successful_extraction(
error_code,
extracted_files,
complete_contents,
download_contents,
temp_complete_dir,
expected_files,
should_delete_original=True,
original_files=tar_files,
)
dropped = {"pivot", "hardlink.txt"}
assert not [f for f in extracted_files if os.path.basename(f) in dropped]
assert not [f for f in complete_contents if os.path.basename(f) in dropped]
def test_pre_existing_link_in_destination(self, tmp_path):
"""A link left in the folder by another unpacker cannot be used to write a member
through it, because tarfile.data_filter() resolves the destination"""
base = str(tmp_path)
extraction_path = os.path.join(base, "dest")
outside = os.path.join(base, "outside")
os.mkdir(extraction_path)
os.mkdir(outside)
os.symlink(outside, os.path.join(extraction_path, "sub"))
tar_path = os.path.join(base, "prelink.tar")
with tarfile.open(tar_path, "w") as tar:
info = tarfile.TarInfo("sub/evil.txt")
info.size = 4
tar.addfile(info, io.BytesIO(b"evil"))
nzo = TestRarUnpack._create_test_nzo(extraction_path)
error_code, extracted_files = newsunpack.tar_extract(nzo, tar_path, extraction_path, False)
assert error_code == 1, "TAR extraction should fail"
assert not extracted_files
assert not os.listdir(outside)
def test_dangling_link_not_used_as_unique_name(self, tmp_path):
"""get_unique_filename() picks the name after data_filter() approved the original, so
a dangling link is neither a free name nor a way out of the folder"""
base = str(tmp_path)
extraction_path = os.path.join(base, "dest")
os.mkdir(extraction_path)
outside = os.path.join(base, "outside.txt")
# Occupy the plain name, so a unique one has to be picked
with open(os.path.join(extraction_path, "victim.txt"), "wb") as victim:
victim.write(b"keep")
# The name that gets picked next is a link that points out of the folder
os.symlink(os.path.join("..", "outside.txt"), os.path.join(extraction_path, "victim.1.txt"))
tar_path = os.path.join(base, "unique.tar")
with tarfile.open(tar_path, "w") as tar:
info = tarfile.TarInfo("victim.txt")
info.size = 4
tar.addfile(info, io.BytesIO(b"evil"))
nzo = TestRarUnpack._create_test_nzo(extraction_path)
newsunpack.tar_extract(nzo, tar_path, extraction_path, False)
assert not os.path.exists(outside), "written through the link to %s" % outside
with open(os.path.join(extraction_path, "victim.txt"), "rb") as victim:
assert victim.read() == b"keep", "the existing file was overwritten"
def test_owner_permissions_sanitized_tar_unpack(self, tmp_path):
tar_path = tmp_path / "owner.tar"
+1 -1
View File
@@ -212,7 +212,7 @@ class TestNzbQueue:
@pytest.mark.skipif(not sabnzbd.WINDOWS, reason="Legacy 3.0.0 queue fixture contains Windows-specific paths")
def test_restore_legacy_queue_format_3_0_0(self, tmp_path, monkeypatch):
fixture_path = Path(SAB_DATA_DIR) / "test_3_0_0_queue_format"
fixture_path = Path(SAB_DATA_DIR) / "test_3_0_0_data_format"
shutil.copytree(fixture_path, tmp_path, dirs_exist_ok=True)
nzbqueue = NzbQueue()
+171 -3
View File
@@ -734,9 +734,15 @@ class TestRSS:
assert job_after_clear.archived_at is not None
assert job_after_clear.is_downloaded
# get_jobs should return all jobs for a feed
# get_jobs should return all non-archived jobs for a feed
jobs_from_get_jobs = list(repo.get_feed_jobs(feed=feed))
assert {j.link for j in jobs_from_get_jobs} == set(links_by_feed[feed])
assert {j.link for j in jobs_from_get_jobs} == {
job_link for job_link in links_by_feed[feed] if job_link is not link
}
# get_jobs archive should return only archived jobs for a feed
jobs_from_get_jobs = list(repo.get_feed_jobs(feed=feed, archive=True))
assert {j.link for j in jobs_from_get_jobs} == {link}
# is_duplicate should detect similar jobs in other feeds
duplicate_candidate = ResolvedEntry(
@@ -775,7 +781,7 @@ class TestRSS:
now = datetime.datetime.now(datetime.timezone.utc)
age = now - datetime.timedelta(weeks=52)
old_seen_at = now - datetime.timedelta(days=4)
old_seen_at = now - datetime.timedelta(days=8)
new_seen_at = now - datetime.timedelta(days=1)
# Old good item that should be kept because it is part of the new_urls set
@@ -1039,3 +1045,165 @@ class TestRSS:
# Shared link must only appear once
assert links == {shared_link, a_only_link, b_only_link}
def test_purge_removed_feeds_only_drops_unconfigured_feeds(self, tmp_rss):
"""Records should only be dropped for feeds that are no longer configured."""
repo, _reader = tmp_rss
configured_feed = "ConfiguredFeed"
removed_feed = "RemovedFeed"
self.setup_rss(configured_feed, "http://example.test/rss.xml")
age = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(weeks=52)
old_seen_at = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=30)
for feed in (configured_feed, removed_feed):
repo.upsert(
ResolvedEntry(
feed=feed,
link=f"http://example.test/{feed}/job",
title=f"{feed} job",
infourl=None,
size=10,
age=age,
seen_at=old_seen_at,
season=1,
episode=1,
category=None,
state=RSSState.GOOD,
)
)
repo.purge_removed_feeds()
assert set(repo.get_feeds()) == {configured_feed}
def test_process_feed_without_readout_keeps_stored_jobs(self, httpserver: HTTPServer, tmp_rss):
"""Replaying stored jobs (readout=False) must not expire or purge anything."""
repo, reader = tmp_rss
feed_name = "NoReadoutFeed"
feed_xml = """<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>NoReadout</title>
<item>
<title>New.Show.S01E01.720p</title>
<link>http://example.test/no-readout/current</link>
<guid>http://example.test/info/no-readout-current</guid>
<category>tv</category>
<pubDate>Wed, 01 Jan 2025 00:00:00 GMT</pubDate>
</item>
</channel>
</rss>
"""
httpserver.expect_request("/rss_no_readout.xml").respond_with_data(feed_xml, content_type="application/rss+xml")
self.setup_rss(feed_name, httpserver.url_for("/rss_no_readout.xml"))
now = datetime.datetime.now(datetime.timezone.utc)
old_url = "http://example.test/no-readout/expired"
repo.upsert(
ResolvedEntry(
feed=feed_name,
link=old_url,
title="Old.Show.S01E01.720p",
infourl=None,
size=10,
age=now - datetime.timedelta(weeks=52),
seen_at=now - datetime.timedelta(days=8),
season=1,
episode=1,
category=None,
state=RSSState.EXPIRED,
)
)
assert reader.process_feed(feed_name, readout=False) == ""
assert repo.find_job_by_url(feed_name, old_url) is not None
# A real readout does not find the link anymore, so it gets purged
assert reader.process_feed(feed_name, readout=True) == ""
assert repo.find_job_by_url(feed_name, old_url) is None
def test_process_feed_without_readout_updates_all_stored_jobs(self, httpserver: HTTPServer, tmp_rss):
"""Re-evaluating stored jobs (readout=False) must persist the new state for every job.
The stored jobs are read through one database connection while each
re-evaluated job is written through another connection to the same
file. If the read cursor is still open while writing, SQLite reports
"database is locked" and the write is silently dropped.
"""
repo, reader = tmp_rss
feed_name = "ReEvaluateFeed"
# Start without any filter: every stored job is BAD
self.setup_rss(feed_name, httpserver.url_for("/unused.xml"))
now = datetime.datetime.now(datetime.timezone.utc)
urls = [f"http://example.test/re-evaluate/{n}" for n in range(5)]
for n, url in enumerate(urls):
repo.upsert(
ResolvedEntry(
feed=feed_name,
link=url,
title=f"Some.Show.S01E0{n}.720p",
infourl=None,
size=1000,
age=now - datetime.timedelta(hours=n + 1),
seen_at=now,
season=1,
episode=n,
category=None,
state=RSSState.BAD,
)
)
assert all(repo.find_job_by_url(feed_name, url).state is RSSState.BAD for url in urls)
# Add an accept-all filter and replay the stored jobs, like the filter editor does
self.setup_rss(feed_name, httpserver.url_for("/unused.xml"), filters=[("", "", "", "A", "*", "", "1")])
assert reader.process_feed(feed_name, readout=False) == ""
for url in urls:
job = repo.find_job_by_url(feed_name, url)
assert job is not None
assert job.state is RSSState.GOOD, f"{url} was not updated"
def test_downloaded_item_still_in_feed_is_not_redownloaded(self, httpserver: HTTPServer, tmp_rss, mocker):
"""An item that stays in the feed must survive the retention period and not be grabbed twice."""
repo, reader = tmp_rss
feed_name = "LongLivedFeed"
link = "http://example.test/long-lived"
feed_xml = f"""<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>LongLived</title>
<item>
<title>Show.S01E01.720p</title>
<link>{link}</link>
<guid>http://example.test/info/long-lived</guid>
<category>tv</category>
<pubDate>Wed, 01 Jan 2025 00:00:00 GMT</pubDate>
</item>
</channel>
</rss>
"""
httpserver.expect_request("/rss_long_lived.xml").respond_with_data(feed_xml, content_type="application/rss+xml")
self.setup_rss(feed_name, httpserver.url_for("/rss_long_lived.xml"))
add_url = mocker.patch("sabnzbd.urlgrabber.add_url")
assert reader.process_feed(feed_name, download=True, force=True) == ""
assert add_url.call_count == 1
assert repo.find_job_by_url(feed_name, link).state is RSSState.DOWNLOADED
# Pretend the item was downloaded longer ago than the retention period, while
# it is still being served by the feed
stale = int((datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=30)).timestamp())
repo.db.execute("UPDATE rss SET seen_at = ? WHERE feed = ?", (stale, feed_name))
# Still being listed should refresh seen_at instead of purging the job
assert reader.process_feed(feed_name, download=True) == ""
job = repo.find_job_by_url(feed_name, link)
assert job is not None
assert job.state is RSSState.DOWNLOADED
assert job.seen_at.timestamp() > stale
# And it must not be picked up as a new job on the next scans
assert reader.process_feed(feed_name, download=True) == ""
assert add_url.call_count == 1
BIN
View File
Binary file not shown.