Compare commits

...
5 Commits
Author SHA1 Message Date
Zaid Humaimidi 944a690d96 1.2.15 2026-03-26 14:12:26 -07:00
Zaid Humaimidi e7facc3bba Changelog for v1.2.15 2026-03-26 14:10:18 -07:00
Zaid Humaimidi aa83b62064 Update dependencies 2026-03-26 13:50:37 -07:00
Zaid Humaimidi f9ee39dfd0 Fix device ID parsing regression in Mithril 2.3.8
Device IDs containing literal % characters (e.g., "202BC1-CODA%2D5519-000000")
are double-encoded in URLs. Mithril 2.3.8 incorrectly double-decodes route
parameters, corrupting device IDs with special characters.

Workaround: Extract device ID directly from window.location.hash and decode
once to get the correct value.

See: https://github.com/MithrilJS/mithril.js/issues/3064
2026-03-26 13:20:19 -07:00
Zaid Humaimidi 087b95388f Fix RCE vulnerability in /api/ping endpoint
This addresses a follow-up to CVE-2021-46704 where the host validation
could be bypassed via domainToASCII stripping path components.

Changes:
- Normalize host with domainToASCII before validation
- Add round-trip check with domainToUnicode to catch path injection
- Switch from exec to execFile to prevent shell injection
- Add -- separator to ping arguments

Credit: JuHwiSang for reporting this vulnerability
2026-03-25 15:00:00 -07:00
5 changed files with 84 additions and 37 deletions

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# Change Log # Change Log
## 1.2.15 (2026-03-26)
- Fix critical RCE vulnerability in /api/ping endpoint (follow-up to
CVE-2021-46704). Credit: JuHwiSang.
- Fix device IDs containing special characters (e.g., %) not being handled
correctly in the UI due to a regression in Mithril 2.3.8.
## 1.2.14 (2026-03-12) ## 1.2.14 (2026-03-12)
- Prevent UI crash when a malformed URL is sent to the server. - Prevent UI crash when a malformed URL is sent to the server.
+16 -7
View File
@@ -1,6 +1,6 @@
import { platform } from "node:os"; import { platform } from "node:os";
import { exec } from "node:child_process"; import { execFile } from "node:child_process";
import { domainToASCII } from "node:url"; import { domainToASCII, domainToUnicode } from "node:url";
export interface PingResult { export interface PingResult {
packetsTransmitted: number; packetsTransmitted: number;
@@ -16,9 +16,16 @@ function isValidHost(host: string): boolean {
// Valid chars in IPv4, IPv6, domain names // Valid chars in IPv4, IPv6, domain names
if (/^[a-zA-Z0-9\-.:[\]-]+$/.test(host)) return true; if (/^[a-zA-Z0-9\-.:[\]-]+$/.test(host)) return true;
const lowercase = host.toLowerCase();
const ascii = domainToASCII(lowercase);
// Check if input is an IDN convert to Punycode // Check if input is an IDN convert to Punycode
// Can't merge with above because domainToASCII doesn't accept IP addresses // Can't merge with above because domainToASCII doesn't accept IP addresses
return /^[a-zA-Z0-9\-.:[\]-]+$/.test(domainToASCII(host)); if (!/^[a-zA-Z0-9\-.:[\]-]+$/.test(ascii)) return false;
// Round-trip to ensure the original host matches its normalized form.
// This prevents bypasses where domainToASCII strips paths (e.g. "example.com/;cmd").
return lowercase === domainToUnicode(ascii);
} }
export function parsePing(osPlatform: string, stdout: string): PingResult { export function parsePing(osPlatform: string, stdout: string): PingResult {
@@ -73,24 +80,26 @@ export function ping(
): void { ): void {
// Validate input to prevent possible remote code execution // Validate input to prevent possible remote code execution
// Credit to Alex Hordijk for reporting this vulnerability // Credit to Alex Hordijk for reporting this vulnerability
// Credit to JuHwiSang for reporting a follow-up vulnerability
if (!isValidHost(host)) return callback(new Error("Invalid host")); if (!isValidHost(host)) return callback(new Error("Invalid host"));
host = host.replace("[", "").replace("]", ""); host = host.replace("[", "").replace("]", "");
let cmd: string; let args: string[];
switch (platform()) { switch (platform()) {
case "linux": case "linux":
cmd = `ping -w 1 -i 0.2 -c 3 ${host}`; args = ["-w", "1", "-i", "0.2", "-c", "3", "--", host];
break; break;
case "freebsd": case "freebsd":
// Send a single packet because on FreeBSD only superuser can send // Send a single packet because on FreeBSD only superuser can send
// packets that are only 200 ms apart. // packets that are only 200 ms apart.
cmd = `ping -t 1 -c 3 ${host}`; args = ["-t", "1", "-c", "3", "--", host];
break; break;
default: default:
return callback(new Error("Platform not supported")); return callback(new Error("Platform not supported"));
} }
exec(cmd, (err, stdout) => { // Use execFile instead of exec to avoid shell interpretation of the host
execFile("ping", args, (err, stdout) => {
if (err) return callback(err); if (err) return callback(err);
const parsed: PingResult = parsePing(platform(), stdout); const parsed: PingResult = parsePing(platform(), stdout);
return callback(err, parsed, stdout); return callback(err, parsed, stdout);
+27 -24
View File
@@ -1,12 +1,12 @@
{ {
"name": "genieacs", "name": "genieacs",
"version": "1.2.14", "version": "1.2.15",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "genieacs", "name": "genieacs",
"version": "1.2.14", "version": "1.2.15",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@breejs/later": "^4.2.0", "@breejs/later": "^4.2.0",
@@ -18,7 +18,7 @@
"ipaddr.js": "^2.3.0", "ipaddr.js": "^2.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"koa": "^2.16.4", "koa": "^2.16.4",
"koa-compress": "^5.2.0", "koa-compress": "^5.2.1",
"koa-jwt": "^4.0.3", "koa-jwt": "^4.0.3",
"koa-send": "^5.0.1", "koa-send": "^5.0.1",
"mongodb": "^4.16.0", "mongodb": "^4.16.0",
@@ -36,7 +36,7 @@
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/koa": "^2.15.0", "@types/koa": "^2.15.0",
"@types/koa-compress": "^4.0.7", "@types/koa-compress": "^4.0.7",
"@types/mithril": "^2.2.7", "@types/mithril": "^2.2.8",
"@types/node": "^20.19.37", "@types/node": "^20.19.37",
"@types/parsimmon": "^1.10.9", "@types/parsimmon": "^1.10.9",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
@@ -51,7 +51,7 @@
"sql.js": "^1.14.1", "sql.js": "^1.14.1",
"svgo": "^3.3.3", "svgo": "^3.3.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"yaml": "^1.10.2" "yaml": "^1.10.3"
}, },
"engines": { "engines": {
"node": ">=12.13.0" "node": ">=12.13.0"
@@ -2080,10 +2080,11 @@
} }
}, },
"node_modules/@types/mithril": { "node_modules/@types/mithril": {
"version": "2.2.7", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.2.8.tgz",
"integrity": "sha512-uetxoYizBMHPELl6DSZUfO6Q/aOm+h0NUCv9bVAX2iAxfrdBSOvU9KKFl+McTtxR13F+BReYLY814pJsZvnSxg==", "integrity": "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
@@ -4016,9 +4017,10 @@
"integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw=="
}, },
"node_modules/koa-compress": { "node_modules/koa-compress": {
"version": "5.2.0", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/koa-compress/-/koa-compress-5.2.0.tgz", "resolved": "https://registry.npmjs.org/koa-compress/-/koa-compress-5.2.1.tgz",
"integrity": "sha512-RsRnI+v+/rs1lYpcAUcxowUzHYssf71qbMr0Mpdq1wktbtXDZmxBIgxJHtaEsBjSe4jiWYELpGFbASa2AemmOg==", "integrity": "sha512-k8VJMZI7vvSq1/G/t6Jggeg4h7fec1NJxcYaDWC4/1PK6mJFPs0OYqj44vx/soUcDVEJR7ysR5cc6pszKpnzXA==",
"license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"compressible": "^2.0.18", "compressible": "^2.0.18",
@@ -5282,10 +5284,11 @@
"dev": true "dev": true
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "1.10.2", "version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true, "dev": true,
"license": "ISC",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
} }
@@ -6824,9 +6827,9 @@
} }
}, },
"@types/mithril": { "@types/mithril": {
"version": "2.2.7", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.2.8.tgz",
"integrity": "sha512-uetxoYizBMHPELl6DSZUfO6Q/aOm+h0NUCv9bVAX2iAxfrdBSOvU9KKFl+McTtxR13F+BReYLY814pJsZvnSxg==", "integrity": "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ==",
"dev": true "dev": true
}, },
"@types/ms": { "@types/ms": {
@@ -8246,9 +8249,9 @@
"integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw=="
}, },
"koa-compress": { "koa-compress": {
"version": "5.2.0", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/koa-compress/-/koa-compress-5.2.0.tgz", "resolved": "https://registry.npmjs.org/koa-compress/-/koa-compress-5.2.1.tgz",
"integrity": "sha512-RsRnI+v+/rs1lYpcAUcxowUzHYssf71qbMr0Mpdq1wktbtXDZmxBIgxJHtaEsBjSe4jiWYELpGFbASa2AemmOg==", "integrity": "sha512-k8VJMZI7vvSq1/G/t6Jggeg4h7fec1NJxcYaDWC4/1PK6mJFPs0OYqj44vx/soUcDVEJR7ysR5cc6pszKpnzXA==",
"requires": { "requires": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"compressible": "^2.0.18", "compressible": "^2.0.18",
@@ -9111,9 +9114,9 @@
"dev": true "dev": true
}, },
"yaml": { "yaml": {
"version": "1.10.2", "version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true "dev": true
}, },
"ylru": { "ylru": {
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "genieacs", "name": "genieacs",
"version": "1.2.14", "version": "1.2.15",
"description": "A TR-069 Auto Configuration Server (ACS)", "description": "A TR-069 Auto Configuration Server (ACS)",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -34,7 +34,7 @@
"ipaddr.js": "^2.3.0", "ipaddr.js": "^2.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"koa": "^2.16.4", "koa": "^2.16.4",
"koa-compress": "^5.2.0", "koa-compress": "^5.2.1",
"koa-jwt": "^4.0.3", "koa-jwt": "^4.0.3",
"koa-send": "^5.0.1", "koa-send": "^5.0.1",
"mongodb": "^4.16.0", "mongodb": "^4.16.0",
@@ -46,7 +46,7 @@
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/koa": "^2.15.0", "@types/koa": "^2.15.0",
"@types/koa-compress": "^4.0.7", "@types/koa-compress": "^4.0.7",
"@types/mithril": "^2.2.7", "@types/mithril": "^2.2.8",
"@types/node": "^20.19.37", "@types/node": "^20.19.37",
"@types/parsimmon": "^1.10.9", "@types/parsimmon": "^1.10.9",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
@@ -61,7 +61,7 @@
"sql.js": "^1.14.1", "sql.js": "^1.14.1",
"svgo": "^3.3.3", "svgo": "^3.3.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"yaml": "^1.10.2" "yaml": "^1.10.3"
}, },
"engines": { "engines": {
"node": ">=12.13.0" "node": ">=12.13.0"
+29 -2
View File
@@ -3,6 +3,29 @@ import { m } from "./components.ts";
import config from "./config.ts"; import config from "./config.ts";
import * as store from "./store.ts"; import * as store from "./store.ts";
// Workaround for Mithril 2.3.8 regression
// https://github.com/MithrilJS/mithril.js/issues/3064
function getPathParamFromHash(hash: string, prefix: string): string | null {
if (!hash.startsWith(prefix)) return null;
const pathWithQuery = hash.slice(prefix.length);
// Stop at query string or hash fragment (mirrors Mithril's parsePathname)
const queryIdx = pathWithQuery.indexOf("?");
const hashIdx = pathWithQuery.indexOf("#");
let pathEnd = pathWithQuery.length;
if (queryIdx >= 0) pathEnd = Math.min(pathEnd, queryIdx);
if (hashIdx >= 0) pathEnd = Math.min(pathEnd, hashIdx);
const rawPath = pathWithQuery.slice(0, pathEnd);
// Normalize: collapse slashes, remove trailing slash (matches Mithril)
const normalizedPath = rawPath.replace(/\/{2,}/g, "/").replace(/\/$/, "");
// Decode once (Mithril already decoded with decodeURIComponentSafe)
return decodeURIComponent(normalizedPath);
}
export function init( export function init(
args: Record<string, unknown>, args: Record<string, unknown>,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
@@ -12,9 +35,13 @@ export function init(
); );
} }
const deviceId =
getPathParamFromHash(window.location.hash, "#!/devices/") ??
(args.id as string);
return Promise.resolve({ return Promise.resolve({
deviceId: args.id, deviceId: deviceId,
deviceFilter: ["=", ["PARAM", "DeviceID.ID"], args.id], deviceFilter: ["=", ["PARAM", "DeviceID.ID"], deviceId],
}); });
} }