mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-09 13:36:07 +09:00
* Harden theme settings auth and CSRF checks * Secure JSON webhook callback validation Adds pre-persistence validation for JSON payment callbacks before any transaction lifecycle work or storage. This change bounds callback input in the IPN entrypoint, introduces a JsonWebhookAuthenticatorInterface, and validates Stripe webhook signatures through a reusable method before processing. It rejects unsupported/invalid gateways and oversized or malformed payloads, and adds unit coverage for missing, invalid, expired, malformed, oversized, and valid signed callbacks. * Harden payment return flow Route gateway return and cancel URLs through a dedicated `/invoice/payment-return` page that only redirects to fixed invoice destinations. This removes the legacy session restore token flow, avoids exposing session capability in payment handoffs, switches PayPal returns to browser redirects (`rm=1`), and adds unit coverage for the new payment return and gateway URL behavior. * Harden admin transaction error rendering Replaced inline `onclick` handling in the invoice transactions template with escaped `data-transaction-error*` attributes, and added a delegated click listener in `fossbilling.ts` to show messages safely. This removes executable inline attributes while preserving diagnostic text. Added unit coverage for both Twig and admin JS to ensure hostile payloads stay as plain text, no script/image injection is introduced, and dynamically inserted error links still behave correctly. * Harden email confirmation token lifecycle Refactors client email confirmation to be transactional and replay-safe by binding tokens to the current email hash, enforcing expiry, and atomically consuming/revoking tokens during approval. Email changes from both admin and profile flows now revoke outstanding confirmation links and reset `email_approved` consistently, including concurrent update scenarios. Adds a dedicated `EmailConfirmationTest` suite covering replay prevention, stale/legacy/expired token rejection, resend behavior, rollback safety, and profile-change edge cases, while removing outdated unit tests that no longer match the new confirmation flow. * Harden invoice PDF logo fetching Invoice PDF generation no longer allows Dompdf to reach remote network sources. Logos are now fetched through a no-private-network client, validated for MIME, size, and safe SVG content, and embedded as data URIs when allowed. Malformed, private, redirecting, or unsafe logo URLs are ignored without interrupting invoice rendering, and the new unit tests cover the security and compatibility cases. * Harden guest login/signup CSRF checks Adds a dedicated guard for guest `client/login` and `client/create` API calls to require POST, validate the pre-login session CSRF nonce, and block cross-site origins/fetch contexts before dispatch. Client-facing login/signup templates now include `CSRFToken` hidden inputs, and test helpers (PHP + Playwright) fetch `/login` first to reuse the anonymous session token. Unit tests were expanded to cover accepted/rejected authentication request paths and token propagation in shipped forms. * Avoid persisting extension config on reads Stop `Extension` config lookups from creating empty `ExtensionMeta` rows, and gate guest settings to core or active modules before reading config. Tests were updated to cover the non-writing read path and to ensure authorized config writes still preserve public settings. * Harden extension archive extraction Improve `downloadAndExtract()` security and cleanup behavior by staging extraction in a private temp directory, using cryptographically secure temp names, validating all ZIP entries before extraction (path, null-byte/colon checks, and allowed file types), and removing staged artifacts on failure. After successful validation/extraction, permissions are restored before moving to the final extension path. Adds unit tests covering rejection of unsafe archive entries before any write occurs and confirming valid package files/directories still extract correctly. * Harden extension directory data handling Adds strict validation for extension IDs and catalog metadata in `ExtensionManager`, including cached responses, and rejects unsafe author URLs/schemes. Updates the admin extensions template to remove inline `onclick` usage and load README content via modal event data attributes, preventing injected names from becoming executable script. Includes new unit tests covering malformed directory payloads, cached validation behavior, safe rendering of quoted names, identifier pre-validation, and valid metadata edge cases. * Harden client password reset revocation Move client password changes and reset issuance into `ClientPasswordResetRepository` so password updates and reset-token revocation happen atomically. Admin, guest, and profile password changes now reuse the same transactional path, preventing stale recovery links from overwriting newer passwords and replacing outstanding reset tokens safely across supported drivers. Added unit coverage for revocation, rollback, stale-token rejection, and driver-specific locking behavior. * Harden staff password reset transactions Refactors admin password changes to go through `AdminPasswordResetRepository` so password updates and reset-token revocation happen atomically in one transaction. Profile and staff password-change flows, plus guest reset redemption/request flows, now reuse repository methods (`changePassword`/`replaceReset`) instead of manually persisting/removing entities. Adds row-lock based eligibility rechecks (including SQLite-specific lock handling) to prevent stale or preloaded reset tokens from being reused across concurrent updates. Unit tests were updated and expanded with a new repository test suite covering revocation behavior, expiry/eligibility checks, rollback safety, side-effect failures, and concurrency races. * Prevent public installer reinstall when debug is enabled * Fix strict-variables signup test context for CSRFToken * Fix PHP 8.5 SVG logo detection and Rector findings Route image/svg+xml reported by getimagesizefromstring to the self-contained SVG validator instead of the raster path. Apply ReturnNeverTypeRector and ReadOnlyAnonymousClassRector suggestions in tests. * Satisfy PHPStan for SVG mime check in PDF logo fetch * Add nosniff and pjpeg to spellcheck dictionary * Reuse CSRF cookie for pre-login API tests Update the test API helper to read the pre-login CSRF token from the cookie jar before falling back to parsing the login page. This keeps signup and other back-to-back guest requests on the same session, even when `/login` redirects or the session id rotates. * Send CSRF token in Playwright stale-login check The password-change test asserts stale credentials are rejected with an invalid-credentials message. Since guest logins now require the pre-login session nonce, submit the double-submit cookie value like the real login form does; otherwise the request is rejected before credentials are checked. This also avoids the retry that pushed the suite over the client_signup rate limit. * Address review findings: rate-limit ordering, staging cleanup, README race - Run guest CSRF validation after the rate limiter so invalid-token floods still consume api_login/api_guest budgets.\n- Remove extension staging dir and archive when destination resolution, the already-installed check, or the final move fails.\n- Ignore stale extension README responses that arrive after another extension was selected. * Check stale passwords from a fresh session in Playwright Changing a password invalidates every session including the current one, so the old session can no longer pass CSRF validation. Run the stale-password login from a fresh anonymous context with its own pre-login nonce, like a real login attempt.
307 lines
8.8 KiB
PHP
307 lines
8.8 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Copyright 2022-2026 FOSSBilling
|
|
* SPDX-License-Identifier: Apache-2.0.
|
|
*
|
|
* @copyright FOSSBilling (https://www.fossbilling.org)
|
|
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Helpers;
|
|
|
|
use Symfony\Component\Filesystem\Filesystem;
|
|
use Symfony\Component\Filesystem\Path;
|
|
|
|
/**
|
|
* API client for E2E testing.
|
|
*/
|
|
class ApiClient
|
|
{
|
|
private static ?string $baseUrl = null;
|
|
private static ?string $apiKey = null;
|
|
private static string $cookiePath;
|
|
|
|
public static function setBaseUrl(string $url): void
|
|
{
|
|
self::$baseUrl = rtrim($url, '/');
|
|
}
|
|
|
|
public static function setApiKey(string $key): void
|
|
{
|
|
self::$apiKey = $key;
|
|
}
|
|
|
|
private static function getCookiePath(): string
|
|
{
|
|
if (!isset(self::$cookiePath)) {
|
|
self::$cookiePath = Path::join(sys_get_temp_dir(), 'fossbilling_e2e_cookies.txt');
|
|
}
|
|
|
|
return self::$cookiePath;
|
|
}
|
|
|
|
public static function request(
|
|
string $endpoint,
|
|
array $payload = [],
|
|
?string $role = null,
|
|
?string $apiKey = null,
|
|
string $method = 'POST',
|
|
): ApiResponse {
|
|
$baseUrl = self::$baseUrl ?? (getenv('APP_URL') ?: 'http://localhost');
|
|
$baseUrl = rtrim($baseUrl, '/');
|
|
$apiKey ??= self::$apiKey ?? getenv('TEST_API_KEY');
|
|
$role ??= str_starts_with($endpoint, 'admin') ? 'admin' : 'client';
|
|
|
|
$url = rtrim($baseUrl, '/') . '/api/' . ltrim($endpoint, '/');
|
|
|
|
if (in_array(strtolower(trim($endpoint, '/')), ['guest/client/login', 'guest/client/create'], true)
|
|
&& !isset($payload['CSRFToken'])) {
|
|
$payload['CSRFToken'] = self::getPreLoginToken($baseUrl);
|
|
}
|
|
|
|
$ch = curl_init();
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
|
CURLOPT_COOKIEJAR => self::getCookiePath(),
|
|
CURLOPT_COOKIEFILE => self::getCookiePath(),
|
|
CURLOPT_USERPWD => "$role:$apiKey",
|
|
]);
|
|
|
|
if (strcasecmp($method, 'POST') === 0) {
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload));
|
|
}
|
|
|
|
$output = curl_exec($ch);
|
|
if ($output === false) {
|
|
$error = curl_error($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
throw new \RuntimeException('cURL request failed: ' . $error . ' (HTTP ' . $httpCode . ')');
|
|
}
|
|
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
return new ApiResponse($httpCode, $output);
|
|
}
|
|
|
|
private static function getPreLoginToken(string $baseUrl): string
|
|
{
|
|
$html = self::fetchLoginPage($baseUrl);
|
|
|
|
// Prefer the double-submit cookie: it mirrors the session token
|
|
// (FOSSBilling\Http\CookieNames::CSRF, set on every client page
|
|
// render) and stays valid across session-id rotation. Unlike the
|
|
// login form markup, it is also available when /login redirects
|
|
// because the jar's session is already authenticated, so back to
|
|
// back signup calls stay on the same session (preserving e.g. the
|
|
// guest cart).
|
|
$token = self::readCookieJarValue('fossbilling_csrf');
|
|
if (is_string($token) && $token !== '') {
|
|
return $token;
|
|
}
|
|
|
|
if (is_string($html) && preg_match('/name="CSRFToken" value="([^"]+)"/', $html, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
throw new \RuntimeException('Could not obtain the pre-login CSRF token');
|
|
}
|
|
|
|
private static function fetchLoginPage(string $baseUrl): mixed
|
|
{
|
|
$ch = curl_init($baseUrl . '/login');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_COOKIEJAR => self::getCookiePath(),
|
|
CURLOPT_COOKIEFILE => self::getCookiePath(),
|
|
]);
|
|
|
|
try {
|
|
return curl_exec($ch);
|
|
} finally {
|
|
// Flush the anonymous session cookie before the following API request.
|
|
curl_close($ch);
|
|
}
|
|
}
|
|
|
|
private static function readCookieJarValue(string $name): ?string
|
|
{
|
|
$path = self::getCookiePath();
|
|
if (!is_file($path)) {
|
|
return null;
|
|
}
|
|
|
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
if ($lines === false) {
|
|
return null;
|
|
}
|
|
|
|
// The jar may hold several generations of cookies; the last non-empty match wins.
|
|
$value = null;
|
|
foreach ($lines as $line) {
|
|
if (str_starts_with($line, '#') && !str_starts_with($line, '#HttpOnly.')) {
|
|
continue;
|
|
}
|
|
$parts = explode("\t", (string) preg_replace('/^#HttpOnly\./', '', $line));
|
|
if (count($parts) === 7 && $parts[5] === $name && $parts[6] !== '') {
|
|
$value = $parts[6];
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
public static function resetCookies(): void
|
|
{
|
|
$filesystem = new Filesystem();
|
|
$filesystem->remove(self::getCookiePath());
|
|
}
|
|
|
|
public static function get(string $endpoint, array $payload = [], ?string $role = null): ApiResponse
|
|
{
|
|
return self::request($endpoint, $payload, $role ?? 'admin', null, 'GET');
|
|
}
|
|
|
|
public static function post(string $endpoint, array $payload = [], ?string $role = null): ApiResponse
|
|
{
|
|
return self::request($endpoint, $payload, $role ?? 'admin', null, 'POST');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* API Response wrapper for E2E tests.
|
|
*/
|
|
class ApiResponse
|
|
{
|
|
private array $decodedResponse;
|
|
|
|
public function __construct(
|
|
private readonly int $code,
|
|
private readonly string $rawResponse,
|
|
) {
|
|
$decoded = json_decode($this->rawResponse, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
|
$preview = strlen($this->rawResponse) > 500 ? substr($this->rawResponse, 0, 500) . '...' : $this->rawResponse;
|
|
|
|
throw new \RuntimeException('Invalid JSON response: ' . json_last_error_msg() . ' (HTTP ' . $this->code . '). Response: ' . $preview);
|
|
}
|
|
$this->decodedResponse = $decoded;
|
|
}
|
|
|
|
public function getHttpCode(): int
|
|
{
|
|
return $this->getStatusCode();
|
|
}
|
|
|
|
public function getStatusCode(): int
|
|
{
|
|
return $this->code;
|
|
}
|
|
|
|
public function getResponse(): array
|
|
{
|
|
return $this->decodedResponse;
|
|
}
|
|
|
|
public function getRawResponse(): string
|
|
{
|
|
return $this->rawResponse;
|
|
}
|
|
|
|
public function wasSuccessful(): bool
|
|
{
|
|
return !empty($this->decodedResponse)
|
|
&& array_key_exists('error', $this->decodedResponse)
|
|
&& empty($this->decodedResponse['error'])
|
|
&& array_key_exists('result', $this->decodedResponse);
|
|
}
|
|
|
|
public function getErrorMessage(): string
|
|
{
|
|
return $this->decodedResponse['error']['message'] ?? 'None';
|
|
}
|
|
|
|
public function getErrorCode(): int
|
|
{
|
|
return (int) ($this->decodedResponse['error']['code'] ?? 0);
|
|
}
|
|
|
|
public function getError(): string
|
|
{
|
|
return $this->getErrorMessage() . ' (Code ' . $this->getErrorCode() . ')';
|
|
}
|
|
|
|
public function getResult(): mixed
|
|
{
|
|
return $this->decodedResponse['result'] ?? null;
|
|
}
|
|
|
|
public function hasResult(): bool
|
|
{
|
|
return array_key_exists('result', $this->decodedResponse);
|
|
}
|
|
|
|
public function isArrayResult(): bool
|
|
{
|
|
return is_array($this->decodedResponse['result'] ?? null);
|
|
}
|
|
|
|
public function isIntResult(): bool
|
|
{
|
|
return is_int($this->decodedResponse['result'] ?? null);
|
|
}
|
|
|
|
public function isBoolResult(): bool
|
|
{
|
|
return is_bool($this->decodedResponse['result'] ?? null);
|
|
}
|
|
|
|
public function isStringResult(): bool
|
|
{
|
|
return is_string($this->decodedResponse['result'] ?? null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper function for API requests.
|
|
*/
|
|
function api(string $endpoint, array $payload = [], ?string $role = null): ApiResponse
|
|
{
|
|
return ApiClient::request($endpoint, $payload, $role);
|
|
}
|
|
|
|
/**
|
|
* Assert that an API response was successful.
|
|
*/
|
|
function assertApiSuccess(ApiResponse $response): void
|
|
{
|
|
expect($response->wasSuccessful())
|
|
->toBeTrue($response->getError());
|
|
}
|
|
|
|
/**
|
|
* Assert that an API response has an integer result.
|
|
*/
|
|
function assertApiResultIsInt(ApiResponse $response): void
|
|
{
|
|
expect($response->isIntResult())
|
|
->toBeTrue('Expected integer result, got: ' . gettype($response->getResult()));
|
|
}
|
|
|
|
/**
|
|
* Assert that an API response has an array result.
|
|
*/
|
|
function assertApiResultIsArray(ApiResponse $response): void
|
|
{
|
|
expect($response->isArrayResult())
|
|
->toBeTrue('Expected array result, got: ' . gettype($response->getResult()));
|
|
}
|