mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-07-30 13:11:39 +00:00
Compare commits
10 commits
43ac8c23c7
...
12b53f15ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12b53f15ba | ||
|
|
07d106e44d | ||
|
|
16bb09c153 | ||
|
|
9504553e88 | ||
|
|
ad539e00a7 | ||
|
|
c6983c6516 | ||
|
|
a47e56bd93 | ||
|
|
65631dde4c | ||
|
|
3f7dd59dd1 | ||
|
|
9fb69bfc03 |
14 changed files with 400 additions and 102 deletions
|
|
@ -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") ---
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.13.2
|
||||
2.13.3
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<br>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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
],
|
||||
|
|
|
|||
96
src/Services/System/TrustedUrlGenerator.php
Normal file
96
src/Services/System/TrustedUrlGenerator.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, mixed> $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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
<h4>{% trans with {'%name%': user.fullName|escape } %}email.hi %name%{% endtrans %},</h4>
|
||||
{% trans %}email.pw_reset.message{% endtrans %}
|
||||
<br>
|
||||
<button class="large expand" href="{{ url('pw_reset_new_pw', {user: user.name, token: token}) }}">{% trans %}email.pw_reset.button{% endtrans %}</button>
|
||||
<button class="large expand" href="{{ reset_url }}">{% trans %}email.pw_reset.button{% endtrans %}</button>
|
||||
<br>
|
||||
{% trans with {'%url%': url('pw_reset_new_pw') } %}email.pw_reset.fallback{% endtrans %}:
|
||||
{% trans with {'%url%': reset_url_fallback } %}email.pw_reset.fallback{% endtrans %}:
|
||||
<callout class="secondary">
|
||||
<row>
|
||||
<columns>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]));
|
||||
|
|
|
|||
172
tests/Services/System/TrustedUrlGeneratorTest.php
Normal file
172
tests/Services/System/TrustedUrlGeneratorTest.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -13695,6 +13695,24 @@ Buerklin-API-Authentication-Server:
|
|||
<target>Sie können diesen zufällig generierten Wert verwenden (geben Sie ihn niemandem weiter):</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="OP7MacF" name="system.trusted_hosts.unconfigured.title">
|
||||
<segment state="translated">
|
||||
<source>system.trusted_hosts.unconfigured.title</source>
|
||||
<target>TRUSTED_HOSTS nicht konfiguriert</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="88NkHLV" name="system.trusted_hosts.unconfigured.message">
|
||||
<segment state="translated">
|
||||
<source>system.trusted_hosts.unconfigured.message</source>
|
||||
<target>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.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qZ_kgWT" name="system.trusted_hosts.unconfigured.suggestion">
|
||||
<segment state="translated">
|
||||
<source>system.trusted_hosts.unconfigured.suggestion</source>
|
||||
<target>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>:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cEwxoSj" name="info_providers.provider_key">
|
||||
<segment state="translated">
|
||||
<source>info_providers.provider_key</source>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue