Skip to content

File tree

7 files changed

+408
-0
lines changed

7 files changed

+408
-0
lines changed
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-26wg-9xf2-q495",
4+
"modified": "2026-04-14T23:23:01Z",
5+
"published": "2026-04-14T23:23:01Z",
6+
"aliases": [],
7+
"summary": "Novu has a XSS sanitization bypass",
8+
"details": "### Summary\n\nXSS sanitization is incomplete, some attributes are missing such as `oncontentvisibilityautostatechange=`. This allows for the email preview to render HTML that executes arbitrary JavaScript,\n\n### Details\n\nSanitization is implemented here:\nhttps://github.com/novuhq/novu/blob/next/libs/application-generic/src/services/sanitize/sanitizer.service.ts\n\nWith `allowedAttributes: false`, all attributes are allowed through `sanitize-html`. Even dangerous ones like `oncontentvisibilityautostatechange=`. The `DANGEROUS_ATTRIBUTES` array tries to handle this by denying more attributes after the fact, but this list is incomplete. I copied all well-known payloads from:\nhttps://portswigger.net/web-security/cross-site-scripting/cheat-sheet\nAnd found that the `oncontentvisibilityautostatechange=` attribute isn't detected. \n\nPS. there seems to also be another even more lax sanitizer here, but I wasn't able to figure out where it is used:\nhttps://github.com/novuhq/novu/blob/next/packages/framework/src/utils/sanitize.utils.ts\n\n### PoC\n\n1. Create a new workflow and add an *Email* step\n2. In the body, write the following HTML code:\n\n```html\n<a oncontentvisibilityautostatechange=\"alert(window.origin)\" style=\"display:block;content-visibility:auto\">\n```\n\n3. Wait a second and notice the XSS popup showing the current origin:\n\n<img width=\"1515\" height=\"610\" alt=\"image\" src=\"https://github.com/user-attachments/assets/7d519a50-3bed-4f04-b78c-9c5938717433\" />\n\nhttps://dashboard.novu.co/env/dev_env_gVtdgDEhgf1CetwX/workflows/onboarding-demo-workflow_wf_gVtdh2uV0h7j3ffK/steps/email-step_st_gVtqdgIrOkYVvP9F/editor\n\n### Impact\n\nThis may look like a Self-XSS similar to https://github.com/novuhq/novu/security/advisories/GHSA-w8vm-jx29-52fr, but it can be more impactful. First of all, if multiple users can access this dashboard, the link above can directly bring the to the email step editor to trigger the XSS.\nAn attacker can also use the Google/GitHub OAuth flows without completing the code callback step, and send that URL to the victim to intentionally log the vicitm into the attacker's account. If the attacker has prepared an XSS payload there, they will now be allowed to view it, so it triggers.",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "npm",
19+
"name": "novu/api"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "3.15.0"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/novuhq/novu/security/advisories/GHSA-26wg-9xf2-q495"
40+
},
41+
{
42+
"type": "PACKAGE",
43+
"url": "https://github.com/novuhq/novu"
44+
}
45+
],
46+
"database_specific": {
47+
"cwe_ids": [
48+
"CWE-79"
49+
],
50+
"severity": "HIGH",
51+
"github_reviewed": true,
52+
"github_reviewed_at": "2026-04-14T23:23:01Z",
53+
"nvd_published_at": null
54+
}
55+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-4x48-cgf9-q33f",
4+
"modified": "2026-04-14T23:22:48Z",
5+
"published": "2026-04-14T23:22:48Z",
6+
"aliases": [],
7+
"summary": "Novu has SSRF via conditions filter webhook bypasses validateUrlSsrf() protection",
8+
"details": "## Summary\n\nThe conditions filter webhook at `libs/application-generic/src/usecases/conditions-filter/conditions-filter.usecase.ts` line 261 sends POST requests to user-configured URLs using raw `axios.post()` with no SSRF validation. The HTTP Request workflow step in the same codebase correctly uses `validateUrlSsrf()` which blocks private IP ranges. The conditions webhook was not included in this protection.\n\n## Root Cause\n\n`conditions-filter.usecase.ts` line 261:\n```typescript\nreturn await axios.post(child.webhookUrl, payload, config).then((response) => {\n return response.data as Record<string, unknown>;\n});\n```\n\nNo call to `validateUrlSsrf()`. The `webhookUrl` comes from the workflow condition configuration with zero validation.\n\n## Protected Code (for contrast)\n\n`execute-http-request-step.usecase.ts` line 130:\n```typescript\nconst ssrfValidationError = await validateUrlSsrf(url);\nif (ssrfValidationError) {\n // blocked\n}\n```\n\nThis function resolves DNS and checks against private ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16). It exists in the codebase but is not applied to the conditions webhook path.\n\n## Proof of Concept\n\n1. Create a workflow with a condition step\n2. Configure the condition's webhook URL to `http://169.254.169.254/latest/meta-data/iam/security-credentials/`\n3. Trigger the workflow by sending a notification event\n4. The worker evaluates the condition and calls `axios.post()` to the metadata endpoint\n5. The response data is stored in execution details and accessible via the execution details API\n\n## Impact\n\nFull-read SSRF. The response body is returned as `Record<string, unknown>` for condition evaluation and stored in the execution details `raw` field. The `GET /execution-details` API returns this data.\n\nThe POST method limits some metadata endpoints (GCP requires GET, Azure requires GET), but AWS IMDSv1 accepts POST and returns credentials. Internal services accepting POST are also reachable.\n\n## Suggested Fix\n\nExtract `validateUrlSsrf()` to a shared utility and call it before the axios.post in conditions-filter.usecase.ts:\n\n```typescript\nconst ssrfError = await validateUrlSsrf(child.webhookUrl);\nif (ssrfError) {\n throw new Error('Webhook URL blocked by SSRF protection');\n}\nreturn await axios.post(child.webhookUrl, payload, config)...\n```",
9+
"severity": [],
10+
"affected": [
11+
{
12+
"package": {
13+
"ecosystem": "npm",
14+
"name": "@novu/api"
15+
},
16+
"ranges": [
17+
{
18+
"type": "ECOSYSTEM",
19+
"events": [
20+
{
21+
"introduced": "0"
22+
},
23+
{
24+
"fixed": "3.15.0"
25+
}
26+
]
27+
}
28+
]
29+
}
30+
],
31+
"references": [
32+
{
33+
"type": "WEB",
34+
"url": "https://github.com/novuhq/novu/security/advisories/GHSA-4x48-cgf9-q33f"
35+
},
36+
{
37+
"type": "WEB",
38+
"url": "https://github.com/novuhq/novu/commit/87d965eb88340ac7cd262dd52c8015acd092dc68"
39+
},
40+
{
41+
"type": "PACKAGE",
42+
"url": "https://github.com/novuhq/novu"
43+
}
44+
],
45+
"database_specific": {
46+
"cwe_ids": [
47+
"CWE-918"
48+
],
49+
"severity": "HIGH",
50+
"github_reviewed": true,
51+
"github_reviewed_at": "2026-04-14T23:22:48Z",
52+
"nvd_published_at": null
53+
}
54+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-5879-4fmr-xwf2",
4+
"modified": "2026-04-14T23:21:31Z",
5+
"published": "2026-04-14T23:21:31Z",
6+
"aliases": [],
7+
"summary": "WWBN AVideo has an incomplete fix for CVE-2026-33293: Path Traversal",
8+
"details": "### Summary\n\nThe incomplete fix for AVideo's CloneSite `deleteDump` parameter does not apply path traversal filtering, allowing `unlink()` of arbitrary files via `../../` sequences in the GET parameter.\n\n### Affected Package\n\n- **Ecosystem:** Other\n- **Package:** AVideo\n- **Affected versions:** < commit 941decd6d19e\n- **Patched versions:** >= commit 941decd6d19e\n\n### Details\n\nAt line 44-48 of `cloneServer.json.php` (pre-fix):\n```php\nif (!empty($_GET['deleteDump'])) {\n $resp->error = !unlink(\"{$clonesDir}{$_GET['deleteDump']}\");\n $resp->msg = \"Delete Dump {$_GET['deleteDump']}\";\n die(json_encode($resp));\n}\n```\n\nNo `basename()`, no `realpath()` check, no path traversal filtering. `$_GET['deleteDump']` is concatenated directly with `$clonesDir`.\n\nThe vulnerable code has zero protection against path traversal:\n- No `basename()` to strip directory components\n- No `realpath()` to validate the final path\n- No check that resolved path is within `$clonesDir`\n- No `../` sanitization\n- Additionally, `exec()` calls with `mysqldump` pass credentials on the command line\n\n### PoC\n\n```python\n\"\"\"\nCVE-2026-33293 - AVideo CloneSite Path Traversal\n\"\"\"\n\nimport sys\nimport os\n\nVULN_SRC = os.path.join(os.path.dirname(__file__), \"src\", \"cloneServer.json.php\")\n\ndef verify_source_file():\n if not os.path.isfile(VULN_SRC):\n print(\"ERROR: Source not found at %s\" % VULN_SRC)\n sys.exit(1)\n with open(VULN_SRC, \"r\") as f:\n src = f.read()\n if \"unlink(\" not in src or \"deleteDump\" not in src:\n print(\"ERROR: Expected patterns not found\")\n sys.exit(1)\n return src\n\ndef vulnerable_delete_path(clones_dir, delete_dump):\n return clones_dir + delete_dump\n\ndef test_path_traversal():\n clones_dir = \"/var/www/html/AVideo/videos/clones/\"\n payloads = [\n (\"../../configuration.php\", \"Delete site configuration\"),\n (\"../../../etc/passwd\", \"Delete system file\"),\n (\"../../.htaccess\", \"Delete .htaccess\"),\n ]\n\n print(\"Testing path traversal via deleteDump parameter:\")\n print(\"Base clones_dir: %s\" % clones_dir)\n print()\n\n all_traversal = True\n for payload, desc in payloads:\n resolved = vulnerable_delete_path(clones_dir, payload)\n real_resolved = os.path.normpath(resolved)\n escaped = not real_resolved.startswith(os.path.normpath(clones_dir))\n\n if escaped:\n print(\"[+] TRAVERSAL: %s\" % desc)\n print(\" Payload: deleteDump=%s\" % payload)\n print(\" unlink() target: %s\" % resolved)\n print(\" Normalized: %s\" % real_resolved)\n else:\n all_traversal = False\n\n return all_traversal\n\ndef main():\n print(\"=\" * 70)\n print(\"CVE-2026-33293: AVideo CloneSite Path Traversal PoC\")\n print(\"=\" * 70)\n print()\n\n src = verify_source_file()\n print(\"[+] Source file verified: %s\" % VULN_SRC)\n\n for line in src.split('\\n'):\n if 'unlink(' in line and 'deleteDump' in line:\n print(\"[+] Vulnerable line: %s\" % line.strip())\n break\n print()\n\n if test_path_traversal():\n print(\"\\nVULNERABILITY CONFIRMED\")\n sys.exit(0)\n else:\n print(\"\\nVULNERABILITY NOT CONFIRMED\")\n sys.exit(1)\n\nif __name__ == \"__main__\":\n main()\n```\n\n```bash\npython3 poc.py\n```\n\n**Steps to reproduce:**\n1. `git clone https://github.com/WWBN/AVideo /tmp/AVideo_test`\n2. `cd /tmp/AVideo_test && git checkout 941decd6d19e2e694acb75e86317d10fbb560284~1`\n3. `python3 poc.py`\n\n**Expected output:**\n```\nVULNERABILITY CONFIRMED\nThe deleteDump parameter passes unsanitized path traversal sequences (../../) directly to unlink(), enabling arbitrary file deletion.\n```\n\n### Impact\n\nAn attacker can delete arbitrary files on the server. Deleting `configuration.php` takes the site offline. Deleting `.htaccess` exposes protected directories. Deleting system files can affect other services.\n\n### Suggested Remediation\n\nUse `basename($_GET['deleteDump'])` to strip directory components. Validate that `realpath()` of the final path is within `$clonesDir`. Validate file extension. Add authentication checks.",
9+
"severity": [
10+
{
11+
"type": "CVSS_V4",
12+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "Packagist",
19+
"name": "wwbn/avideo"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"last_affected": "29.0"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/WWBN/AVideo/security/advisories/GHSA-5879-4fmr-xwf2"
40+
},
41+
{
42+
"type": "ADVISORY",
43+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33293"
44+
},
45+
{
46+
"type": "WEB",
47+
"url": "https://github.com/WWBN/AVideo/commit/941decd6d19e2e694acb75e86317d10fbb560284"
48+
},
49+
{
50+
"type": "PACKAGE",
51+
"url": "https://github.com/WWBN/AVideo"
52+
}
53+
],
54+
"database_specific": {
55+
"cwe_ids": [
56+
"CWE-22"
57+
],
58+
"severity": "MODERATE",
59+
"github_reviewed": true,
60+
"github_reviewed_at": "2026-04-14T23:21:31Z",
61+
"nvd_published_at": null
62+
}
63+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-8pv3-29pp-pf8f",
4+
"modified": "2026-04-14T23:22:21Z",
5+
"published": "2026-04-14T23:22:21Z",
6+
"aliases": [],
7+
"summary": "WWBN AVideo has Stored XSS via Unanchored Duration Regex in Video Encoder Receiver",
8+
"details": "## Summary\n\nThe `isValidDuration()` regex at `objects/video.php:918` uses `/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}/` without a `$` end anchor, allowing arbitrary HTML/JavaScript to be appended after a valid duration prefix. The crafted duration is stored in the database and rendered without HTML escaping via `echo Video::getCleanDuration()` on trending pages, playlist pages, and video gallery thumbnails, resulting in stored cross-site scripting.\n\n## Details\n\n**Input entry point:** `objects/aVideoEncoderReceiveImage.json.php:208`\n\n```php\n// Line 203-211\nif (!empty($_REQUEST['duration'])) {\n $video->setDuration($_REQUEST['duration']);\n}\n```\n\n**Insufficient validation:** `objects/video.php:918`\n\n```php\nstatic function isValidDuration($duration) {\n // ...\n return preg_match('/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}/', $duration);\n // Missing $ anchor here -----------------------------------^\n}\n```\n\nThe regex matches `00:00:01` at the start of the string but ignores everything after it. A payload like `00:00:01</time><img src=x onerror=alert(1)><time>` passes validation.\n\n**No sanitization in output function:** `objects/video.php:3463-3480`\n\n```php\npublic static function getCleanDuration($duration = \"\") {\n $durationParts = explode(\".\", $duration);\n $duration = $durationParts[0];\n $durationParts = explode(':', $duration);\n if (count($durationParts) == 1) {\n return '0:00:' . static::addZero($durationParts[0]);\n } elseif (count($durationParts) == 2) {\n return '0:' . static::addZero($durationParts[0]) . ':' . static::addZero($durationParts[1]);\n }\n return $duration; // Returns full string unmodified for 3+ colon parts\n}\n```\n\nWith the payload `00:00:01</time><img src=x onerror=alert(1)><time>`, exploding by `:` yields 3+ parts, so the full unsanitized string is returned.\n\n**Unescaped output sinks:**\n\n1. `view/trending.php:72`:\n```php\n<time class=\"duration\"><?php echo Video::getCleanDuration($value['duration']); ?></time>\n```\n\n2. `view/include/playlist.php:159`:\n```php\n<time class=\"duration\"><?php echo Video::getCleanDuration(@$value['duration']); ?></time>\n```\n\n3. `objects/video.php:7200` (gallery thumbnail generation):\n```php\n$img .= \"<time class=\\\"duration\\\"...>\" . $duration . \"</time>\";\n```\n\nNo Content-Security-Policy headers are set. The application uses raw PHP templates with no auto-escaping framework.\n\n## PoC\n\n1. Authenticate as a user with upload permission and obtain a `video_id_hash` for a video (visible in encoder API responses or via the upload flow).\n\n2. Send the malicious duration:\n\n```bash\ncurl -X POST \"https://target/objects/aVideoEncoderReceiveImage.json.php\" \\\n -d \"videos_id=VIDEO_ID\" \\\n -d \"video_id_hash=HASH\" \\\n -d 'duration=00:00:01</time><img src=x onerror=alert(document.cookie)><time>'\n```\n\n3. The `isValidDuration()` regex matches the `00:00:01` prefix and allows the full string to be stored.\n\n4. Visit the trending page (`/view/trending.php`) or any playlist containing the poisoned video. The injected HTML breaks out of the `<time>` tag and the `onerror` handler executes JavaScript in the victim's browser context.\n\n## Impact\n\n- **Session hijacking**: Attacker can steal session cookies of any user (including administrators) who views a page listing the poisoned video (trending, playlists, search results, channel pages).\n- **Account takeover**: Stolen admin session cookies grant full platform control.\n- **Phishing**: Attacker can inject fake login forms or redirect users to malicious sites.\n- **Worm potential**: Since the XSS fires on commonly-visited listing pages (trending), it can propagate without targeted delivery — any visitor is a victim.\n\nThe attack requires only upload-level permissions (low privilege) and impacts all users who view any page rendering the poisoned video's duration (high blast radius).\n\n## Recommended Fix\n\n**Fix 1 — Anchor the regex** (`objects/video.php:918`):\n\n```php\n- return preg_match('/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}/', $duration);\n+ return preg_match('/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}(\\.[0-9]+)?$/', $duration);\n```\n\n**Fix 2 — HTML-escape all duration output** (defense in depth):\n\nIn `view/trending.php:72`:\n```php\n- <time class=\"duration\"><?php echo Video::getCleanDuration($value['duration']); ?></time>\n+ <time class=\"duration\"><?php echo htmlspecialchars(Video::getCleanDuration($value['duration']), ENT_QUOTES, 'UTF-8'); ?></time>\n```\n\nIn `view/include/playlist.php:159`:\n```php\n- <time class=\"duration\"><?php echo Video::getCleanDuration(@$value['duration']); ?></time>\n+ <time class=\"duration\"><?php echo htmlspecialchars(Video::getCleanDuration(@$value['duration']), ENT_QUOTES, 'UTF-8'); ?></time>\n```\n\nIn `objects/video.php:7200`:\n```php\n- $img .= \"<time class=\\\"duration\\\"...>\" . $duration . \"</time>\";\n+ $img .= \"<time class=\\\"duration\\\"...>\" . htmlspecialchars($duration, ENT_QUOTES, 'UTF-8') . \"</time>\";\n```\n\nBoth fixes should be applied: the regex fix prevents storage of invalid data, and the output escaping provides defense in depth against any other code path that might store unvalidated durations.",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "Packagist",
19+
"name": "wwbn/avideo"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"last_affected": "29.0"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/WWBN/AVideo/security/advisories/GHSA-8pv3-29pp-pf8f"
40+
},
41+
{
42+
"type": "WEB",
43+
"url": "https://github.com/WWBN/AVideo/commit/bcba324644df8b4ed1f891462455f1cd26822a45"
44+
},
45+
{
46+
"type": "PACKAGE",
47+
"url": "https://github.com/WWBN/AVideo"
48+
}
49+
],
50+
"database_specific": {
51+
"cwe_ids": [
52+
"CWE-79"
53+
],
54+
"severity": "MODERATE",
55+
"github_reviewed": true,
56+
"github_reviewed_at": "2026-04-14T23:22:21Z",
57+
"nvd_published_at": null
58+
}
59+
}

0 commit comments

Comments
 (0)