Setelah menganalisis kode newpass.inc.php secara langsung dari repository, ditemukan beberapa vulnerability nyata. Berikut penjelasannya:

Vulnerability yang Ditemukan

1. πŸ”΄ Token Tidak Diinvalidasi Setelah Digunakan (Broken Auth)

Ini adalah celah paling kritis. Setelah password berhasil diupdate, token forgot di database di-clear (forgot=''), namun ada jeda waktu berbahaya β€” validasi salt dilakukan di awal halaman ($_q = $dbs->query($query)), tetapi cek $_q->num_rows > 0 dilakukan kembali di dalam blok POST . Artinya, hasil query pertama di-reuse, dan tidak ada mekanisme rate limiting atau one-time-use enforcement yang ketat selama halaman aktif.

2. πŸ”΄ Enumeration User via Timing/Response

php$query = sprintf("SELECT user_id,realname FROM user WHERE email='%s' AND forgot='%s'", $email, $salt);

Meski sudah menggunakan escape_string, respons halaman berbeda antara email valid vs tidak valid :

  • Email ditemukan β†’ form muncul
  • Email tidak ditemukan β†’ pesan error spesifik

Ini memungkinkan user enumeration β€” attacker bisa mengonfirmasi email mana yang terdaftar di sistem.

3. 🟑 Salt/Token di URL (GET Parameter)

php$email = $dbs->escape_string($_GET['email']);
$salt  = $dbs->escape_string($_GET['salt']);

Token reset password dikirim via query string URL . Ini berisiko karena:

  • Tercatat diΒ server access log,Β browser history, danΒ Referer header
  • Bisa bocor ke pihak ketiga jika ada resource eksternal di halaman

4. 🟑 Tidak Ada Expiry Time pada Token

Tidak ada kolom timestamp atau validasi waktu kedaluwarsa di query :

phpWHERE email='%s' AND forgot='%s'

Token forgot berlaku selamanya sampai digunakan, selama tidak ada mekanisme expiry di tempat lain. Jika token bocor (via log), attacker punya waktu tak terbatas untuk menggunakannya.

5. 🟑 CSRF Protection Tidak Terlihat

Form POST untuk update password tidak memiliki CSRF token yang terlihat di kode ini . Tanpa proteksi CSRF, attacker yang mengetahui email dan salt korban bisa membuat request dari domain lain untuk mengganti password.


Rekomendasi Perbaikan

IssueFix
Token expiryTambah kolom forgot_expire dan validasi WHERE forgot_expire > NOW()
Token di URLGunakan POST form atau simpan token di session
User enumerationKembalikan pesan generik untuk semua kasus error
CSRFTambah csrf_token di form dan validasi di server
Rate limitingBatasi jumlah request reset password per IP/email per jam

Sebagai developer SLIMS Bulian yang aktif, kamu bisa membuat patch atau melaporkannya ke maintainer via GitHub Issues slims/slims9_bulian agar diperbaiki di versi berikutnya.

contoh attacker flownya ?

Berikut attack flow konkret berdasarkan setiap vulnerability di newpass.inc.php:


Attack Flow 1: Token Harvesting via Log (Kritikal)

Ini skenario paling realistis jika attacker punya akses ke server log.

text1. Victim request reset password β†’ sistem kirim link:
   https://perpus.example.com/index.php?p=newpass
   &email=victim@gmail.com&salt=abc123xyz

2. Attacker akses /var/log/apache2/access.log atau nginx log:
   GET /index.php?p=newpass&email=victim@gmail.com&salt=abc123xyz

3. Token tidak expired β†’ attacker buka URL yang sama

4. Form muncul β†’ attacker isi password baru β†’ TAKEOVER βœ…

Siapa yang bisa akses log? Shared hosting user lain, ex-admin, atau attacker yang sudah dapat LFI di sistem.


Attack Flow 2: User Enumeration

Attacker tidak tahu email mana yang terdaftar di SLIMS. Bisa bruteforce:

text1. Attacker kirim GET request massal:
   ?p=newpass&email=test1@gmail.com&salt=random
   ?p=newpass&email=admin@perpus.ac.id&salt=random
   ?p=newpass&email=pustakawan@lib.go.id&salt=random

2. Cek respons HTML:
   β†’ "Current email not found or salt key has been expired"
     = email TIDAK terdaftar ❌

   β†’ Form <input type="password"> muncul
     = email TERDAFTAR βœ… (salt salah, tapi email valid terkonfirmasi)

3. Attacker kumpulkan daftar email valid β†’ dipakai untuk spear phishing
   atau dikombinasikan dengan credential stuffing

Attack Flow 3: CSRF Password Reset

Jika attacker sudah tahu email dan salt korban (misal dari log publik):

xml<!-- Halaman jahat yang dibuka korban -->
<html>
<body onload="document.forms[0].submit()">
<form method="POST"
  action="https://perpus.example.com/index.php?p=newpass
          &email=victim@gmail.com&salt=abc123xyz">
  <input name="newPasswd"  value="hacked123">
  <input name="newPasswd2" value="hacked123">
  <input name="updatePassword" value="Update">
</form>
</body>
</html>
text1. Korban mengklik link sembarang
2. Browser korban auto-submit form β†’ password berubah tanpa sepengetahuan korban
3. Attacker login dengan password "hacked123" βœ…

Berhasil karena tidak ada CSRF token di form.


Attack Flow 4: Token Brute Force (No Expiry)

text1. Attacker tahu email target: admin@perpus.ac.id
   (didapat dari user enumeration di Flow 2)

2. Tidak tahu salt β†’ bruteforce:
   ?email=admin@perpus.ac.id&salt=aaaa0001
   ?email=admin@perpus.ac.id&salt=aaaa0002
   ... (tidak ada rate limiting, tidak ada lockout)

3. Jika salt pendek/predictable β†’ ditemukan dalam hitungan jam

4. Setelah salt cocok β†’ form password muncul β†’ takeover βœ…

Seberapa feasible? Tergantung panjang dan entropi salt yang di-generate di forgotpass.inc.php. Jika hanya md5(time()) atau rand(), sangat rentan.


Urutan Prioritas Fix

textπŸ”΄ 1. Tambah expiry time  β†’ kolom forgot_expire DATETIME
πŸ”΄ 2. Invalidasi token    β†’ hapus forgot setelah 1x pakai (sudah ada, tapi race condition)
🟑 3. Tambah CSRF token   β†’ pakai $_SESSION['csrf_token']
🟑 4. Rate limiting       β†’ cek IP di tabel login_attempt
🟒 5. Generic error msg   β†’ jangan bedakan "email tidak ada" vs "salt salah"

contoh perbaikanSelesai 2 langkah

Berikut contoh perbaikan kode newpass.inc.php yang sudah dihardening untuk semua vulnerability yang ditemukan:


Perbaikan Complete: lib/contents/newpass.inc.php

<?php
/**
 * Librarian password reset page (SECURED VERSION)
 * Copyright (C) 2007,2008  Arie Nugraha, Hendro Wicaksono
 * Security patches: 2026 - Ade Ismail Siregar
 */

// be sure that this file not accessed directly
if (!defined('INDEX_AUTH')) {
    die("can not access this file directly");
} elseif (INDEX_AUTH != 1) { 
    die("can not access this file directly");
}

// required file
require LIB.'admin_logon.inc.php';
require SIMBIO.'simbio_DB/simbio_dbop.inc.php';

// HTTPS connection (if enabled)
if ($sysconf['https_enable']) {
    simbio_security::doCheckHttps($sysconf['https_port']);
}

// ============================================
// SECURITY FIX #1: CSRF Token Validation
// ============================================
if (!isset($_SESSION['csrf_token']) || empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// ============================================
// SECURITY FIX #2: Get parameters from POST (not GET)
// Token sekarang tidak tersimpan di URL
// ============================================
$email = isset($_POST['email']) ? $dbs->escape_string($_POST['email']) : '';
$salt  = isset($_POST['salt'])  ? $dbs->escape_string($_POST['salt'])  : '';

// Validate email & salt are provided
if (empty($email) || empty($salt)) {
    utility::jsAlert(__('Invalid password reset request. Please request a new reset link.'));
    echo '<script>window.location.href = "index.php?p=login";</script>';
    exit();
}

// ============================================
// SECURITY FIX #3: Rate Limiting (IP-based)
// Maksimal 3 request reset password per jam per IP
// ============================================
$ip_address = $_SERVER['REMOTE_ADDR'];
$rate_limit_query = $dbs->query(
    "SELECT COUNT(*) as attempts FROM password_reset_log 
     WHERE ip_address = '{$ip_address}' 
     AND request_time > DATE_SUB(NOW(), INTERVAL 1 HOUR)"
);
$rate_limit = $rate_limit_query->fetch_assoc();

if ($rate_limit['attempts'] >= 3) {
    utility::jsAlert(__('Too many password reset attempts. Please try again in 1 hour.'));
    echo '<script>window.location.href = "index.php?p=login";</script>';
    exit();
}

// Log the request for rate limiting
$dbs->query(
    "INSERT INTO password_reset_log (ip_address, email, request_time) 
     VALUES ('{$ip_address}', '{$email}', NOW())"
);

$url = $_SERVER['SCRIPT_NAME'];

// ============================================
// SECURITY FIX #4: Add expiry time validation
// Token hanya valid 1 jam setelah dibuat
// ============================================
$query = sprintf(
    "SELECT user_id, realname, forgot_expire FROM user 
     WHERE email='%s' AND forgot='%s' AND forgot_expire > NOW()",
    $email,
    $salt
);
$_q = $dbs->query($query);
$file_d = $_q->fetch_assoc();

// ============================================
// SECURITY FIX #5: Generic error message (no enumeration)
// Pesan yang sama untuk semua kasus error
// ============================================
if ($_q->num_rows <= 0) {
    utility::jsAlert(__('Invalid or expired password reset link. Please request a new one.'));
    echo '<script>window.location.href = "index.php?p=login";</script>';
    exit();
}

$_uname = $file_d['realname'];
$user_id = $file_d['user_id'];

// ============================================
// SECURITY FIX #6: One-time-use token (invalidasi sebelum proses)
// ============================================
$dbs->query(
    "UPDATE user SET forgot_temp = sent('forgot') 
     WHERE user_id = {$user_id}"
);

// update password
if (isset($_POST['updatePassword'])) {
    
    // ============================================
    // SECURITY FIX #7: CSRF Token Verification
    // ============================================
    if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
        utility::jsAlert(__('Invalid request. Please try again.'));
        echo '<script>window.location.href = "index.php?p=newpass&email={$email}&salt={$salt}";</script>';
        exit();
    }
    
    $passwd = trim($_POST['newPasswd']);
    $passwd2 = trim($_POST['newPasswd2']);
    
    // ============================================
    // SECURITY FIX #8: Password complexity validation
    // ============================================
    if (empty($passwd)) {
        utility::jsAlert(__('Password cannot be empty!'));
    } else if (strlen($passwd) < 8) {
        utility::jsAlert(__('Password must be at least 8 characters long.'));
    } else if (!preg_match('/[A-Z]/', $passwd) || !preg_match('/[0-9]/', $passwd)) {
        utility::jsAlert(__('Password must contain at least one uppercase letter and one number.'));
    } else if (($passwd AND $passwd2) AND ($passwd !== $passwd2)) {
        utility::jsAlert(__('Password confirmation does not match. See if your Caps Lock key is on!'));
    } else {
        // Double-check token validity (race condition protection)
        $double_check = $dbs->query(
            "SELECT user_id FROM user WHERE user_id = {$user_id} AND forgot_temp IS NOT NULL"
        );
        
        if ($double_check->num_rows > 0) {
            // ============================================
            // SECURITY FIX #9: Atomic token invalidation
            // Invalidasi token DAN update password dalam 1 query
            // ============================================
            $_sql_update_password = sprintf(
                "UPDATE user SET passwd = '%s', last_update = CURDATE(), 
                 forgot='', forgot_temp=NULL, forgot_expire=NULL 
                 WHERE user_id = {$user_id}",
                password_hash($passwd2, PASSWORD_BCRYPT)
            );
            $_update_q = $dbs->query($_sql_update_password);
            
            if ($dbs->error) {
                utility::jsAlert(__('Failed to update password. Please try again.'));
            } else {
                // write log
                writeLog('staff', $_uname, 'Login', 
                    'Change password SUCCESS for user '.$_uname.' from address '.$_SERVER['REMOTE_ADDR'], 
                    'Password', 'Update');

                // clear cookie
                setcookie('token', '', [
                    'expires' => time()-3600,
                    'path' => SWB,
                    'domain' => '',
                    'secure' => true,  // FIX: true jika HTTPS
                    'httponly' => true,
                    'samesite' => 'Strict',  // FIX: Strict untuk proteksi CSRF
                ]);

                setcookie('uname', '', [
                    'expires' => time()-3600,
                    'path' => SWB,
                    'domain' => '',
                    'secure' => true,
                    'httponly' => true,
                    'samesite' => 'Strict',
                ]);

                utility::jsAlert(__('Password has been updated successfully.'));
                echo '<script>window.location.href = "index.php?p=login";</script>';
            }
        } else {
            utility::jsAlert(__('Password reset link has already been used. Please request a new one.'));
        }
    }
}

// generate new CSRF token untuk next request
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

// start the output buffering for main content
ob_start();
?>

<div id="loginForm">
    <noscript>
        <div style="font-weight: bold; color: #FF0000;"><?php echo __('Your browser does not support Javascript or Javascript is disabled. Application won\'t run without Javascript!'); ?><div>
    </noscript>
    
    <form action="<?php echo $url ?>" method="post">
        <!-- SECURITY FIX: Hidden fields untuk email & salt (tidak di URL) -->
        <input type="hidden" name="email" value="<?php echo htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?>">
        <input type="hidden" name="salt" value="<?php echo htmlspecialchars($salt, ENT_QUOTES, 'UTF-8') ?>">
        
        <!-- SECURITY FIX: CSRF Token -->
        <input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
        
        <div class="heading1"><?php echo __('New Password'); ?></div>
        <div class="login_input">
            <input type="password" name="newPasswd" class="login_input" 
                   minlength="8" required autocomplete="new-password" />
        </div>
        
        <div class="heading1"><?php echo __('Confirm New Password'); ?></div>
        <div class="login_input">
            <input type="password" name="newPasswd2" class="login_input" 
                   minlength="8" required autocomplete="new-password" />
        </div>
        
        <div class="marginTop">
            <input type="submit" name="updatePassword" value="<?php echo __('Update'); ?>" 
                   class="loginButton" />
        </div>
    </form>
</div>

<script type="text/javascript">
    // Password strength feedback
    document.querySelector('form').addEventListener('submit', function(e) {
        const pass1 = document.querySelector('input[name="newPasswd"]').value;
        const pass2 = document.querySelector('input[name="newPasswd2"]').value;
        
        if (pass1 !== pass2) {
            e.preventDefault();
            alert('<?php echo __('Passwords do not match!'); ?>');
            return false;
        }
        
        if (pass1.length < 8) {
            e.preventDefault();
            alert('<?php echo __('Password must be at least 8 characters!'); ?>');
            return false;
        }
    });
</script>

<?php
// main content
$main_content = ob_get_clean();
$page_title = __('Create new password').' | '.$sysconf['library_name'];

if ($sysconf['template']['base'] == 'html') {
    $template = new simbio_template_parser($sysconf['template']['dir'].'/'.$sysconf['template']['theme'].'/login_template.html');
    $template->assign('<!--PAGE_TITLE-->', $page_title);
    $template->assign('<!--CSS-->', $sysconf['template']['css']);
    $template->assign('<!--MAIN_CONTENT-->', $main_content);
    $template->printOut();
} else if ($sysconf['template']['base'] == 'php') {
    require_once $sysconf['template']['dir'].'/'.$sysconf['template']['theme'].'/login_template.inc.php';
}

exit();
?>





Database Migration Required

Tambah column baru di tabel user untuk support security patches ini:

sql-- Table untuk rate limiting
CREATE TABLE IF NOT EXISTS `password_reset_log` (
  `id` INT AUTO_INCREMENT PRIMARY KEY,
  `ip_address` VARCHAR(45) NOT NULL,
  `email` VARCHAR(255) NOT NULL,
  `request_time` DATETIME NOT NULL,
  INDEX `idx_ip_time` (`ip_address`, `request_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- Tambah column di tabel user
ALTER TABLE `user` 
  ADD COLUMN `forgot_expire` DATETIME NULL DEFAULT NULL AFTER `forgot`,
  ADD COLUMN `forgot_temp` VARCHAR(255) NULL DEFAULT NULL AFTER `forgot_expire`;

Perbaikan di File forgotpass.inc.php (Email Reset Link)

Ketika sistem mengirim email reset link, jangan kirim token di URL. Gunakan ini:

php// Di forgotpass.inc.php, saat generate token:
$salt = bin2hex(random_bytes(32));  // 64-char hex = 256-bit entropy
$expire = date('Y-m-d H:i:s', strtotime('+1 hour'));

// Simpan ke database
$dbs->query(sprintf(
    "UPDATE user SET forgot='%s', forgot_expire='%s' 
     WHERE email='%s'",
    $salt,
    $expire,
    $email
));

// Kirim email dengan link yang POST token ke session
//subscriber: https://perpus.example.com/index.php?p=newpass_confirm
//&email=".urlencode($email)
// Ganti dengan form POST yang otomatis submit

Ringkasan Perbaikan

IssuePerbaikanLine Context
Token di URLGunakan hidden POST fieldLine 25-28
Tidak ada expiryTambah forgot_expire + validasi > NOW()Line 47
User enumerationGeneric error message untuk semua kasusLine 54-58
CSRFCSRF token di session + validasiLine 19-22, 84-90
Token tidak invalidasiDouble-check + atomic updateLine 84, 103-107
Tidak ada rate limitingRate limit 3x/jam per IPLine 32-43
Password lemahValidasi complexity (8+ char, uppercase, angka)Line 95-101
Cookie tidak securesecure: true, samesite: StrictLine 120-126

Kamu bisa langsung implementasi ini sebagai plugin atau patch untuk SLIMS Buli