diff --git a/.docker/debian-manual-guide/Dockerfile b/.docker/debian-manual-guide/Dockerfile index 73d0cee9..e3fb174b 100644 --- a/.docker/debian-manual-guide/Dockerfile +++ b/.docker/debian-manual-guide/Dockerfile @@ -1,8 +1,8 @@ # Reproduces https://docs.part-db.de/installation/installation_guide-debian.html -# on Debian 12 (bookworm) as closely as possible, for debugging issues reported +# on Debian 13 (trixie) as closely as possible, for debugging issues reported # by users who followed that guide (Apache2 + mod_php, not the project's own # FrankenPHP-based Dockerfile at the repo root). -FROM debian:12 +FROM debian:13 ARG HOST_UID=1000 ARG HOST_GID=1000 @@ -17,12 +17,12 @@ RUN apt-get update && apt-get upgrade -y && \ && rm -rf /var/lib/apt/lists/* # --- PHP and Apache2 (guide: "PHP and Apache2 Setup") --- -# Debian 12 ships PHP 8.2 by default, so no extra PHP repo is needed. +# Debian 13 ships PHP 8.4 by default, so no extra PHP repo is needed. RUN apt-get update && apt-get install -y --no-install-recommends \ - apache2 php8.2 libapache2-mod-php8.2 \ - php8.2-opcache php8.2-curl php8.2-gd php8.2-mbstring \ - php8.2-xml php8.2-bcmath php8.2-intl php8.2-zip php8.2-xsl \ - php8.2-sqlite3 php8.2-mysql \ + apache2 php8.4 libapache2-mod-php8.4 \ + php8.4-opcache php8.4-curl php8.4-gd php8.4-mbstring \ + php8.4-xml php8.4-bcmath php8.4-intl php8.4-zip php8.4-xsl \ + php8.4-sqlite3 php8.4-mysql \ && rm -rf /var/lib/apt/lists/* # --- Composer (guide: "Composer Installation") --- diff --git a/VERSION b/VERSION index 0e83a9a9..a1a4224d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.13.2 +2.13.3 diff --git a/docs/configuration.md b/docs/configuration.md index 2b97dec6..6bf59529 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -262,7 +262,15 @@ See the [information providers]({% link usage/information_provider_system.md %}) * `TRUSTED_PROXIES` (env only): Set the IP addresses (or IP blocks) of trusted reverse proxies here. This is needed to get correct IP information (see [here](https://symfony.com/doc/current/deployment/proxies.html) for more info). * `TRUSTED_HOSTS` (env only): To prevent `HTTP Host header attacks` you can set a regex containing all host names via which Part-DB - should be accessible. If accessed via the wrong hostname, an error will be shown. + should be accessible. If accessed via a hostname not matching the regex, an error page will be shown instead. By default this + is empty, meaning Part-DB accepts requests for any host name, which is not recommended for production use. + + For example, if Part-DB should only be reachable under `part-db.example.invalid`, set: + ``` + TRUSTED_HOSTS='^(part-db\.example\.invalid)$' + ``` + You can specify multiple host names separated by `|`, e.g. `^(localhost|part-db\.example\.invalid)$`. + Part-DB displays a warning on the homepage (visible to administrators only) as long as this value is not set. * `DEMO_MODE` (env only): Set Part-DB into demo mode, which forbids users to change their passwords and settings. Used for the demo instance. This should not be needed for normal installations. * `NO_URL_REWRITE_AVAILABLE` (allowed values `true` or `false`) (env only): Set this value to true, if your webserver does not diff --git a/docs/installation/installation_docker.md b/docs/installation/installation_docker.md index 97b1e662..0d5f8a8b 100644 --- a/docs/installation/installation_docker.md +++ b/docs/installation/installation_docker.md @@ -84,7 +84,11 @@ services: # If you use a reverse proxy in front of Part-DB, you must configure the trusted proxies IP addresses here (see reverse proxy documentation for more information): # - TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - + + # To prevent HTTP Host header attacks, set this to a regex matching all host names Part-DB should be reachable under. + # By default (unset) Part-DB accepts requests for any host name, which is not recommended for production use. + # - TRUSTED_HOSTS=^(part-db\.example\.invalid)$ + # If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here: # The packages will be installed automatically when the container starts # - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer @@ -96,7 +100,10 @@ services: ```bash openssl rand -hex 32 ``` -6. Inside the folder, run +6. Uncomment and set the `TRUSTED_HOSTS` variable to a regex matching the host name(s) Part-DB should be reachable under + (e.g. `^(part-db\.example\.invalid)$`), to prevent HTTP Host header attacks. See [Configuration]({% link configuration.md %}) + for more information. +7. Inside the folder, run ```bash docker-compose up -d @@ -107,7 +114,7 @@ openssl rand -hex 32 > Otherwise Part-DB console might use the wrong configuration to execute commands. -7. Create the initial database with +8. Create the initial database with ```bash docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate @@ -115,7 +122,7 @@ docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate and watch for the password output -8. Part-DB is available under `http://localhost:8080` and you can log in with the username `admin` and the password shown +9. Part-DB is available under `http://localhost:8080` and you can log in with the username `admin` and the password shown before The docker image uses a SQLite database and all data (database, uploads, and other media) is put into folders relative to @@ -171,7 +178,11 @@ services: # Override value if you want to show a given text on homepage. # When this is commented out the webUI can be used to configure the banner #- BANNER=This is a test banner
with a line break - + + # To prevent HTTP Host header attacks, set this to a regex matching all host names Part-DB should be reachable under. + # By default (unset) Part-DB accepts requests for any host name, which is not recommended for production use. + # - TRUSTED_HOSTS=^(part-db\.example\.invalid)$ + # If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here: # - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer diff --git a/docs/installation/installation_guide-debian.md b/docs/installation/installation_guide-debian.md index 2915adbb..77ea8020 100644 --- a/docs/installation/installation_guide-debian.md +++ b/docs/installation/installation_guide-debian.md @@ -1,13 +1,13 @@ --- -title: Direct Installation on Debian 12 +title: Direct Installation on Debian 13 layout: default parent: Installation nav_order: 4 --- -# Part-DB installation guide for Debian 12 (Bookworm) +# Part-DB installation guide for Debian 13 (Trixie) -This guide shows you how to install Part-DB directly on Debian 12 using apache2 and SQLite. This guide should work with +This guide shows you how to install Part-DB directly on Debian 13 using apache2 and SQLite. This guide should work with recent Ubuntu and other Debian-based distributions with little to no changes. Depending on what you want to do, using the prebuilt docker images may be a better choice, as you don't need to install this many dependencies. See [here]({% link installation/installation_docker.md %}) for more information on the docker @@ -45,10 +45,10 @@ compatibility. Install PHP with required extensions and apache2: ```bash -sudo apt install apache2 php8.2 libapache2-mod-php8.2 \ - php8.2-opcache php8.2-curl php8.2-gd php8.2-mbstring \ - php8.2-xml php8.2-bcmath php8.2-intl php8.2-zip php8.2-xsl \ - php8.2-sqlite3 php8.2-mysql +sudo apt install apache2 php8.4 libapache2-mod-php8.4 \ + php8.4-opcache php8.4-curl php8.4-gd php8.4-mbstring \ + php8.4-xml php8.4-bcmath php8.4-intl php8.4-zip php8.4-xsl \ + php8.4-sqlite3 php8.4-mysql ``` ### Install composer @@ -145,6 +145,16 @@ A full list of configuration options can be found [here](../configuration.md). > ``` > or edit the file with a text editor and add a new value for `APP_SECRET` (you can generate a random value with `openssl rand -hex 32`). +{: .important } +> **Set `TRUSTED_HOSTS` before going live.** By default Part-DB accepts requests for any host name, which makes it +> vulnerable to `HTTP Host header attacks`. Edit your `.env.local` with a text editor and set `TRUSTED_HOSTS` to a +> regex matching all host names Part-DB should be reachable under, e.g. if Part-DB should only be reachable via +> `part-db.example.invalid`: +> ``` +> TRUSTED_HOSTS='^(part-db\.example\.invalid)$' +> ``` +> See the [configuration options](../configuration.md) page for more information about `TRUSTED_HOSTS`. + Please check that the configured base currency matches your mainly used currency, as this can not be changed after creating price information. @@ -291,7 +301,7 @@ sudo -u www-data php bin/console cache:clear ## MySQL/MariaDB database To use a MySQL database, follow the steps from above (except the creation of the database, we will do this later). -Debian 12 does not ship MySQL in its repositories anymore, so we use the compatible MariaDB instead: +Debian does not ship MySQL in its repositories anymore, so we use the compatible MariaDB instead: 1. Install maria-db with: diff --git a/docs/installation/reverse-proxy.md b/docs/installation/reverse-proxy.md index 605b93fa..24d502b4 100644 --- a/docs/installation/reverse-proxy.md +++ b/docs/installation/reverse-proxy.md @@ -22,6 +22,16 @@ TRUSTED_PROXIES=192.168.2.10 Set the `DEFAULT_URI` environment variable to the URL of your Part-DB installation, available from the outside (so via the reverse proxy). +You should also set the `TRUSTED_HOSTS` environment variable to a regex matching the host name(s) Part-DB is reachable +under via the reverse proxy, to prevent `HTTP Host header attacks`. For example, if Part-DB is reachable via +`part-db.example.invalid`, set: + +``` +TRUSTED_HOSTS='^(part-db\.example\.invalid)$' +``` + +See the [configuration options](../configuration.md) page for more information about `TRUSTED_HOSTS`. + ## Part-DB in a subpath via reverse proxy If you put Part-DB into a subpath via the reverse proxy, you have to configure your webserver to include `X-Forwarded-Prefix` in the request headers. diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php index 5f251b43..b3da3cda 100755 --- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php +++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php @@ -136,7 +136,9 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider } } - $response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [ + //First we try the search v3 endpoint, which seems to give better results including pictures, but it only works + //on quite exact mpn matches + $response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v3/global", [ 'headers' => [ 'Cookie' => new Cookie('currencyCode', $this->settings->currency) ], @@ -147,20 +149,29 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider $arr = $response->toArray(); - // Get products list - $products = $arr['result']['productSearchResultVO']['productList'] ?? []; - // Get product tip - $tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null; + //If we get exact matches, use them + if (!empty($arr['result']['exactMatchResult'])) { + $products = $arr['result']['exactMatchResult']; + } else { //Otherwise fallback onto the third search endpoint, which has a worse data quality but is more likely to return results for vague search terms + $response = $this->lcscClient->request('POST', self::ENDPOINT_URL."/search/third", [ + 'headers' => [ + 'Cookie' => new Cookie('currencyCode', $this->settings->currency) + ], + 'json' => [ + 'keyword' => $term, + 'currentPage' => 1, + 'pageSize' => 10, + ], + ]); + + $arr = $response->toArray(); + + // Get products list + $products = $arr['result']['productList'] ?? []; + } $result = []; - // LCSC does not display LCSC codes in the search, instead taking you directly to the - // detailed product listing. It does so utilizing a product tip field. - // If product tip exists and there are no products in the product list try a detail query - if (count($products) === 0 && $tipProductCode !== null) { - $result[] = $this->queryDetail($tipProductCode, $lightweight); - } - foreach ($products as $product) { $result[] = $this->getPartDetail($product, $lightweight); } @@ -219,7 +230,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider provider_key: $this->getProviderKey(), provider_id: $product['productCode'], name: $product['productModel'], - description: $this->sanitizeField($product['productIntroEn']), + description: $this->sanitizeField($product['productIntroEn']) ?? '', category: $this->sanitizeField($category ?? null), manufacturer: $this->sanitizeField($product['brandNameEn'] ?? null), mpn: $this->sanitizeField($product['productModel'] ?? null), @@ -383,7 +394,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider ]); } else { // Search API call for other terms - $responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [ + $responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v3/global", [ 'headers' => [ 'Cookie' => new Cookie('currencyCode', $this->settings->currency) ], diff --git a/src/Services/System/TrustedUrlGenerator.php b/src/Services/System/TrustedUrlGenerator.php new file mode 100644 index 00000000..3e023a47 --- /dev/null +++ b/src/Services/System/TrustedUrlGenerator.php @@ -0,0 +1,96 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\System; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +/** + * Generates absolute URLs for routes whose host can be trusted, for use in contexts where the host must not + * be attacker-controllable (e.g. links sent out via email). + * + * If no TRUSTED_HOSTS is configured, Symfony does not validate the Host header of the incoming request in + * any way, so it must not be trusted to generate such links (otherwise an attacker could poison them via a + * forged Host header). In that case, the host/scheme/base path are forced to the ones configured via + * DEFAULT_URI instead of the ones from the current HTTP request. + * If TRUSTED_HOSTS is configured, Symfony already rejects requests with a non-matching Host header, so the + * request's host can be trusted and is used as usual (which allows the app to be reachable under multiple + * trusted hostnames). + */ +final class TrustedUrlGenerator +{ + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + private readonly TrustedHostsChecker $trustedHostsChecker, + #[Autowire('%partdb.default_uri%')] + private readonly string $defaultUri, + ) { + } + + /** + * @param array $parameters + */ + public function generate(string $route, array $parameters = []): string + { + //If TRUSTED_HOSTS is configured, Symfony already validated the request's Host header, so it can be trusted + if (!$this->trustedHostsChecker->isTrustedHostsUnconfigured()) { + return $this->urlGenerator->generate($route, $parameters, UrlGeneratorInterface::ABSOLUTE_URL); + } + + $parts = parse_url(rtrim($this->defaultUri, '/')); + + $context = $this->urlGenerator->getContext(); + + //Backup the original context, so we can restore it after generating the URL + $original = [ + 'host' => $context->getHost(), + 'scheme' => $context->getScheme(), + 'httpPort' => $context->getHttpPort(), + 'httpsPort' => $context->getHttpsPort(), + 'baseUrl' => $context->getBaseUrl(), + ]; + + $scheme = $parts['scheme'] ?? 'https'; + $context->setScheme($scheme); + $context->setHost($parts['host'] ?? ''); + if (isset($parts['port'])) { + if ($scheme === 'https') { + $context->setHttpsPort($parts['port']); + } else { + $context->setHttpPort($parts['port']); + } + } + $context->setBaseUrl($parts['path'] ?? ''); + + try { + return $this->urlGenerator->generate($route, $parameters, UrlGeneratorInterface::ABSOLUTE_URL); + } finally { + //Restore the original context, so the rest of the request is not affected + $context->setHost($original['host']); + $context->setScheme($original['scheme']); + $context->setHttpPort($original['httpPort']); + $context->setHttpsPort($original['httpsPort']); + $context->setBaseUrl($original['baseUrl']); + } + } +} diff --git a/src/Services/UserSystem/PasswordResetManager.php b/src/Services/UserSystem/PasswordResetManager.php index 1a78cb60..d826703f 100644 --- a/src/Services/UserSystem/PasswordResetManager.php +++ b/src/Services/UserSystem/PasswordResetManager.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\UserSystem; use App\Entity\UserSystem\User; +use App\Services\System\TrustedUrlGenerator; use DateTime; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bridge\Twig\Mime\TemplatedEmail; @@ -39,7 +40,8 @@ class PasswordResetManager public function __construct(protected MailerInterface $mailer, protected EntityManagerInterface $em, protected TranslatorInterface $translator, protected UserPasswordHasherInterface $userPasswordEncoder, - PasswordHasherFactoryInterface $encoderFactory) + PasswordHasherFactoryInterface $encoderFactory, + protected TrustedUrlGenerator $trustedUrlGenerator) { $this->passwordEncoder = $encoderFactory->getPasswordHasher(User::class); } @@ -63,6 +65,9 @@ class PasswordResetManager $user->setPwResetExpires($expiration_date); if ($user->getEmail() !== null && $user->getEmail() !== '') { + $reset_url = $this->trustedUrlGenerator->generate('pw_reset_new_pw', ['user' => $user->getName(), 'token' => $unencrypted_token]); + $reset_url_fallback = $this->trustedUrlGenerator->generate('pw_reset_new_pw'); + $address = new Address($user->getEmail(), $user->getFullName()); $mail = new TemplatedEmail(); $mail->to($address); @@ -72,6 +77,8 @@ class PasswordResetManager 'expiration_date' => $expiration_date, 'token' => $unencrypted_token, 'user' => $user, + 'reset_url' => $reset_url, + 'reset_url_fallback' => $reset_url_fallback, ]); //Send email diff --git a/src/Settings/InfoProviderSystem/CanopySettings.php b/src/Settings/InfoProviderSystem/CanopySettings.php index 3c97a80e..dd36fba7 100644 --- a/src/Settings/InfoProviderSystem/CanopySettings.php +++ b/src/Settings/InfoProviderSystem/CanopySettings.php @@ -72,7 +72,7 @@ class CanopySettings /** * @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant */ - #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'translation_domain' => false])] + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'choice_translation_domain' => false])] public string $domain = "DE"; /** diff --git a/templates/mail/pw_reset.html.twig b/templates/mail/pw_reset.html.twig index 18a867d6..f9b9662c 100644 --- a/templates/mail/pw_reset.html.twig +++ b/templates/mail/pw_reset.html.twig @@ -6,9 +6,9 @@

{% trans with {'%name%': user.fullName|escape } %}email.hi %name%{% endtrans %},

{% trans %}email.pw_reset.message{% endtrans %}
- +
- {% trans with {'%url%': url('pw_reset_new_pw') } %}email.pw_reset.fallback{% endtrans %}: + {% trans with {'%url%': reset_url_fallback } %}email.pw_reset.fallback{% endtrans %}: diff --git a/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php b/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php index 9ec3abe1..63e1b452 100644 --- a/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php +++ b/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php @@ -133,25 +133,21 @@ final class LCSCProviderTest extends TestCase { $mockResponse = new MockResponse(json_encode([ 'result' => [ - 'productSearchResultVO' => [ - 'productList' => [ - [ - 'productCode' => 'C789012', - 'productModel' => 'Regular Component', - 'productIntroEn' => 'Regular description', - 'brandNameEn' => 'Regular Manufacturer', - 'encapStandard' => '0805', - 'productImageUrl' => 'https://example.com/regular.jpg', - 'productImages' => ['https://example.com/regular1.jpg'], - 'productPriceList' => [ - ['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€'] - ], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ] - ] - ] + 'exactMatchResult' => [[ + 'productCode' => 'C789012', + 'productModel' => 'Regular Component', + 'productIntroEn' => 'Regular description', + 'brandNameEn' => 'Regular Manufacturer', + 'encapStandard' => '0805', + 'productImageUrl' => 'https://example.com/regular.jpg', + 'productImages' => ['https://example.com/regular1.jpg'], + 'productPriceList' => [ + ['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€'] + ], + 'paramVOList' => [], + 'pdfUrl' => null, + 'weight' => null + ]] ] ])); @@ -166,45 +162,6 @@ final class LCSCProviderTest extends TestCase $this->assertSame('Regular Component', $results[0]->name); } - public function testSearchByKeywordWithTipProduct(): void - { - $mockResponse = new MockResponse(json_encode([ - 'result' => [ - 'productSearchResultVO' => [ - 'productList' => [] - ], - 'tipProductDetailUrlVO' => [ - 'productCode' => 'C555555' - ] - ] - ])); - - $detailResponse = new MockResponse(json_encode([ - 'result' => [ - 'productCode' => 'C555555', - 'productModel' => 'Tip Component', - 'productIntroEn' => 'Tip description', - 'brandNameEn' => 'Tip Manufacturer', - 'encapStandard' => '1206', - 'productImageUrl' => null, - 'productImages' => [], - 'productPriceList' => [], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse, $detailResponse]); - - $results = $this->provider->searchByKeyword('special'); - - $this->assertIsArray($results); - $this->assertCount(1, $results); - $this->assertInstanceOf(PartDetailDTO::class, $results[0]); - $this->assertSame('C555555', $results[0]->provider_id); - $this->assertSame('Tip Component', $results[0]->name); - } public function testSearchByKeywordsBatch(): void { @@ -310,7 +267,7 @@ final class LCSCProviderTest extends TestCase ] ])); - $this->httpClient->setResponseFactory([$mockResponse]); + $this->httpClient->setResponseFactory([$mockResponse, $mockResponse]); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('No part found with ID INVALID'); @@ -322,8 +279,7 @@ final class LCSCProviderTest extends TestCase { $mockResponse = new MockResponse(json_encode([ 'result' => [ - 'productSearchResultVO' => [ - 'productList' => [ + 'exactMatchResult' => [ [ 'productCode' => 'C123456', 'productModel' => 'Component 1', @@ -350,7 +306,6 @@ final class LCSCProviderTest extends TestCase 'pdfUrl' => null, 'weight' => null ] - ] ] ] ])); diff --git a/tests/Services/System/TrustedUrlGeneratorTest.php b/tests/Services/System/TrustedUrlGeneratorTest.php new file mode 100644 index 00000000..40c7c2c5 --- /dev/null +++ b/tests/Services/System/TrustedUrlGeneratorTest.php @@ -0,0 +1,172 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Services\System; + +use App\Services\System\TrustedHostsChecker; +use App\Services\System\TrustedUrlGenerator; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\RequestContext; + +final class TrustedUrlGeneratorTest extends TestCase +{ + private function createGenerator(UrlGeneratorInterface $urlGenerator, string $trustedHosts, string $defaultUri): TrustedUrlGenerator + { + return new TrustedUrlGenerator($urlGenerator, new TrustedHostsChecker($trustedHosts), $defaultUri); + } + + public function testUsesCurrentRequestContextWhenTrustedHostsIsConfigured(): void + { + //If TRUSTED_HOSTS is configured, the request's host is already validated by Symfony, so it should be used as-is + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->never())->method('getContext'); + $urlGenerator->expects($this->once()) + ->method('generate') + ->with('pw_reset_new_pw', ['user' => 'john', 'token' => 'abc'], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn('https://request-host.example.com/pw_reset/new_pw/john/abc'); + + $generator = $this->createGenerator($urlGenerator, 'example\.com', 'https://trusted.example.com/'); + + $url = $generator->generate('pw_reset_new_pw', ['user' => 'john', 'token' => 'abc']); + $this->assertSame('https://request-host.example.com/pw_reset/new_pw/john/abc', $url); + } + + public function testForcesDefaultUriHostWhenTrustedHostsIsNotConfigured(): void + { + //If TRUSTED_HOSTS is not configured, the request's Host header is attacker-controllable, so the + //host/scheme/base path configured via DEFAULT_URI must be used instead. + $context = new RequestContext(); + $context->setScheme('http'); + $context->setHost('attacker.evil'); + $context->setBaseUrl(''); + + $capturedHost = null; + $capturedScheme = null; + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('getContext')->willReturn($context); + $urlGenerator->expects($this->once()) + ->method('generate') + ->with('pw_reset_new_pw', ['user' => 'john', 'token' => 'abc'], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturnCallback(function () use ($context, &$capturedHost, &$capturedScheme) { + //Capture the context state as it is seen by the URL generator during the call + $capturedHost = $context->getHost(); + $capturedScheme = $context->getScheme(); + + return $capturedScheme.'://'.$capturedHost.'/pw_reset/new_pw/john/abc'; + }); + + $generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com/'); + + $url = $generator->generate('pw_reset_new_pw', ['user' => 'john', 'token' => 'abc']); + + $this->assertSame('trusted.example.com', $capturedHost); + $this->assertSame('https', $capturedScheme); + $this->assertSame('https://trusted.example.com/pw_reset/new_pw/john/abc', $url); + } + + public function testRestoresOriginalContextAfterGenerating(): void + { + //The context is shared with the rest of the application, so it must not be left modified afterward + $context = new RequestContext(); + $context->setScheme('http'); + $context->setHost('attacker.evil'); + $context->setHttpPort(8080); + $context->setBaseUrl('/original'); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('getContext')->willReturn($context); + $urlGenerator->method('generate')->willReturn('https://trusted.example.com/foo'); + + $generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com/sub/'); + $generator->generate('some_route'); + + $this->assertSame('attacker.evil', $context->getHost()); + $this->assertSame('http', $context->getScheme()); + $this->assertSame(8080, $context->getHttpPort()); + $this->assertSame('/original', $context->getBaseUrl()); + } + + public function testRestoresOriginalContextEvenWhenGenerateThrows(): void + { + $context = new RequestContext(); + $context->setScheme('http'); + $context->setHost('attacker.evil'); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('getContext')->willReturn($context); + $urlGenerator->method('generate')->willThrowException(new \RuntimeException('route not found')); + + $generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com/'); + + try { + $generator->generate('unknown_route'); + $this->fail('Expected exception was not thrown'); + } catch (\RuntimeException) { + //Expected + } + + $this->assertSame('attacker.evil', $context->getHost()); + $this->assertSame('http', $context->getScheme()); + } + + public function testUsesHttpsPortForHttpsDefaultUri(): void + { + $context = new RequestContext(); + + $capturedHttpsPort = null; + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('getContext')->willReturn($context); + $urlGenerator->method('generate')->willReturnCallback(function () use ($context, &$capturedHttpsPort) { + $capturedHttpsPort = $context->getHttpsPort(); + + return 'https://trusted.example.com:8443/foo'; + }); + + $generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com:8443/'); + $generator->generate('some_route'); + + $this->assertSame(8443, $capturedHttpsPort); + } + + public function testUsesBasePathFromDefaultUri(): void + { + $context = new RequestContext(); + + $capturedBaseUrl = null; + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('getContext')->willReturn($context); + $urlGenerator->method('generate')->willReturnCallback(function () use ($context, &$capturedBaseUrl) { + $capturedBaseUrl = $context->getBaseUrl(); + + return 'https://trusted.example.com/partdb/foo'; + }); + + $generator = $this->createGenerator($urlGenerator, '', 'https://trusted.example.com/partdb/'); + $generator->generate('some_route'); + + $this->assertSame('/partdb', $capturedBaseUrl); + } +} diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 5542a6ed..15801601 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -13695,6 +13695,24 @@ Buerklin-API-Authentication-Server: Sie können diesen zufällig generierten Wert verwenden (geben Sie ihn niemandem weiter): + + + system.trusted_hosts.unconfigured.title + TRUSTED_HOSTS nicht konfiguriert + + + + + system.trusted_hosts.unconfigured.message + Die Umgebungsvariable <code>TRUSTED_HOSTS</code> ist nicht gesetzt. Das bedeutet, dass Part-DB Anfragen für jeden beliebigen Hostnamen akzeptiert, was ein Sicherheitsrisiko darstellen kann (z. B. HTTP-Host-Header-Injection). Es wird empfohlen, dies auf die Hostnamen zu beschränken, unter denen Part-DB tatsächlich erreichbar ist. + + + + + system.trusted_hosts.unconfigured.suggestion + Legen Sie einen regulären Ausdruck fest, der alle Hostnamen abdeckt, unter denen Part-DB erreichbar sein soll, z. B. basierend auf dem Hostnamen, den Sie für den Zugriff auf diese Seite verwendet haben. Dies ist möglich in Ihrer Datei <code>.env.local</code> oder <code>docker-compose.yaml</code>: + + info_providers.provider_key