Audit fixes for SMS tool (5 issues)
- XSS: render from_number/code via textContent, drop innerHTML string concat - Poll throttle: last_check column + 3s min between SMSPool /sms/check calls - provider_cost: store REAL + CAST in admin SUM (was silently summing text as 0) - Atomic debit: _debit() with balance_sats >= ? guard, debit-before-provision, refund ledger entry on provision failure (no double-spend window) - _extract_sms: defensive field parsing (text/body/message/content, number/sender/from) - admin: add PROVIDER COST card (USD)
This commit is contained in:
80
sms.py
80
sms.py
@@ -137,6 +137,9 @@ MAX_RENTAL_MIN = 20 # VERIFIED 2026-09-20: SMSPool ignores `expiry` param — r
|
||||
# hard-cap at 1200s (20 min) regardless of what's requested. Longer
|
||||
# sessions require a re-rent loop (not yet implemented).
|
||||
|
||||
POLL_THROTTLE_SEC = 3 # min seconds between SMSPool /sms/check calls per session —
|
||||
# caps upstream API load + spend regardless of client poll rate.
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Schema
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -156,7 +159,8 @@ CREATE TABLE IF NOT EXISTS sms_sessions (
|
||||
provider_cost TEXT,
|
||||
created_at INTEGER,
|
||||
expires_at INTEGER,
|
||||
sms_received_at INTEGER
|
||||
sms_received_at INTEGER,
|
||||
last_check INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sms_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -189,6 +193,10 @@ CREATE TABLE IF NOT EXISTS sms_services_cache (
|
||||
def init_sms():
|
||||
d = db.get_db()
|
||||
d.executescript(SCHEMA)
|
||||
# migrations
|
||||
cols = [r[1] for r in d.execute("PRAGMA table_info(sms_sessions)")]
|
||||
if "last_check" not in cols:
|
||||
d.execute("ALTER TABLE sms_sessions ADD COLUMN last_check INTEGER")
|
||||
if d.execute("SELECT COUNT(*) c FROM sms_pricing").fetchone()["c"] == 0:
|
||||
for p in DEFAULT_PRICING:
|
||||
d.execute("INSERT INTO sms_pricing (duration_min, price_sats, active) VALUES (?,?,1)",
|
||||
@@ -270,6 +278,27 @@ def _session_messages(session_id):
|
||||
return rows
|
||||
|
||||
|
||||
def _debit(user_id, amount_sats, type_, ref):
|
||||
"""Atomically deduct a wallet balance, returning True on success.
|
||||
|
||||
The `AND balance_sats >= ?` guard makes the check+deduct a single atomic
|
||||
UPDATE — no double-spend window under concurrent rent requests. Inserts the
|
||||
matching ledger entry only when the debit actually landed.
|
||||
"""
|
||||
d = db.get_db()
|
||||
cur = d.execute(
|
||||
"UPDATE users SET balance_sats = balance_sats - ? WHERE id=? AND balance_sats >= ?",
|
||||
(amount_sats, user_id, amount_sats))
|
||||
if cur.rowcount == 0:
|
||||
d.close()
|
||||
return False
|
||||
d.execute("INSERT INTO transactions (user_id, amount_sats, type, ref, ts) VALUES (?,?,?,?,?)",
|
||||
(user_id, -amount_sats, type_, ref, int(time.time())))
|
||||
d.commit()
|
||||
d.close()
|
||||
return True
|
||||
|
||||
|
||||
def _service_name(service_id):
|
||||
for sid, name in POPULAR_SERVICES:
|
||||
if sid == service_id:
|
||||
@@ -332,14 +361,28 @@ def _all_services():
|
||||
|
||||
|
||||
def _extract_sms(chk):
|
||||
"""Return (body, from_number) from a /sms/check response, or (None, None)."""
|
||||
"""Return (body, from_number) from a /sms/check response, or (None, None).
|
||||
|
||||
Defensive: SMSPool's exact "SMS received" field names aren't fully documented,
|
||||
so check every plausible location. Only treat status==1 (or an explicit
|
||||
sms/text payload) as a received message — never the top-level `message` field
|
||||
(which carries status text like "This order has been refunded").
|
||||
"""
|
||||
if not isinstance(chk, dict):
|
||||
return None, None
|
||||
status = chk.get("status")
|
||||
sms = chk.get("sms")
|
||||
if isinstance(sms, dict) and sms.get("text"):
|
||||
return sms["text"], (sms.get("number") or sms.get("sender") or "")
|
||||
if chk.get("text"):
|
||||
return chk["text"], (chk.get("number") or chk.get("sender") or "")
|
||||
if isinstance(sms, dict):
|
||||
body = (sms.get("text") or sms.get("body") or sms.get("message")
|
||||
or sms.get("content") or "")
|
||||
frm = (sms.get("number") or sms.get("sender") or sms.get("from") or "")
|
||||
if body:
|
||||
return body, frm
|
||||
if status == 1:
|
||||
body = chk.get("text") or chk.get("body") or chk.get("content") or ""
|
||||
frm = chk.get("number") or chk.get("sender") or chk.get("from") or ""
|
||||
if body:
|
||||
return body, frm
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -393,29 +436,37 @@ def init_sms_app(app, login_required, current_user):
|
||||
if price is None:
|
||||
flash("Invalid duration.", "warn")
|
||||
return redirect(url_for("sms_home"))
|
||||
if db.get_balance_sats(u["id"]) < price:
|
||||
flash("Insufficient balance. Deposit BTC in your wallet first.", "warn")
|
||||
return redirect(url_for("wallet"))
|
||||
|
||||
country = next((c for c in _enabled_countries() if c["id"] == country_id), None)
|
||||
if not country:
|
||||
flash("Country unavailable.", "warn")
|
||||
return redirect(url_for("sms_home"))
|
||||
|
||||
# debit BEFORE provisioning — atomic check+deduct, refunded if provision fails
|
||||
ref = db.random_token(12)
|
||||
if not _debit(u["id"], price, "sms", ref):
|
||||
flash("Insufficient balance. Deposit BTC in your wallet first.", "warn")
|
||||
return redirect(url_for("wallet"))
|
||||
|
||||
try:
|
||||
res = PROVIDER.provision_number(service_id, country_id, min(duration, MAX_RENTAL_MIN))
|
||||
except Exception as e:
|
||||
db.add_transaction(u["id"], price, "sms_refund", ref)
|
||||
flash(f"Provider error: {e}", "warn")
|
||||
return redirect(url_for("sms_home"))
|
||||
|
||||
if not res.get("success"):
|
||||
msg = (res.get("errors") or [{}])[0].get("message") or res.get("message") or "No numbers available."
|
||||
db.add_transaction(u["id"], price, "sms_refund", ref)
|
||||
flash(f"Number unavailable: {msg}", "warn")
|
||||
return redirect(url_for("sms_home"))
|
||||
|
||||
order_id = res.get("order_id") or res.get("orderid")
|
||||
number = res.get("number") or res.get("phonenumber")
|
||||
cost = res.get("cost", "0")
|
||||
try:
|
||||
cost = float(res.get("cost", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
cost = 0.0
|
||||
service_name = _service_name(service_id)
|
||||
|
||||
d = db.get_db()
|
||||
@@ -424,11 +475,10 @@ def init_sms_app(app, login_required, current_user):
|
||||
"country_name, country_cc, provider_order_id, number, status, price_sats, "
|
||||
"provider_cost, created_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(u["id"], service_id, service_name, country_id, country["name"], country["cc"],
|
||||
order_id, number, "active", price, str(cost), int(time.time()),
|
||||
order_id, number, "active", price, cost, int(time.time()),
|
||||
int(time.time()) + duration * 60))
|
||||
d.commit()
|
||||
d.close()
|
||||
db.add_transaction(u["id"], -price, "sms", order_id)
|
||||
flash(f"Number {number} reserved for {duration} min.", "ok")
|
||||
return redirect(url_for("sms_home"))
|
||||
|
||||
@@ -454,7 +504,9 @@ def init_sms_app(app, login_required, current_user):
|
||||
d.commit()
|
||||
sess["status"] = "expired"
|
||||
|
||||
if sess["status"] == "active":
|
||||
if sess["status"] == "active" and now - (sess.get("last_check") or 0) >= POLL_THROTTLE_SEC:
|
||||
d.execute("UPDATE sms_sessions SET last_check=? WHERE id=?", (now, sid))
|
||||
d.commit()
|
||||
try:
|
||||
chk = PROVIDER.check_sms(sess["provider_order_id"])
|
||||
except Exception:
|
||||
@@ -539,7 +591,7 @@ def init_sms_app(app, login_required, current_user):
|
||||
"total_sessions": d.execute("SELECT COUNT(*) c FROM sms_sessions").fetchone()["c"],
|
||||
"sms_received": d.execute("SELECT COUNT(*) c FROM sms_messages").fetchone()["c"],
|
||||
"revenue_sats": d.execute("SELECT COALESCE(SUM(amount_sats),0) s FROM transactions WHERE type='sms'").fetchone()["s"],
|
||||
"provider_cost": d.execute("SELECT COALESCE(SUM(provider_cost),0) s FROM sms_sessions").fetchone()["s"],
|
||||
"provider_cost_usd": d.execute("SELECT COALESCE(SUM(CAST(provider_cost AS REAL)),0) s FROM sms_sessions").fetchone()["s"],
|
||||
}
|
||||
sessions = [dict(r) for r in d.execute(
|
||||
"SELECT s.*, u.username FROM sms_sessions s LEFT JOIN users u ON u.id=s.user_id "
|
||||
|
||||
Reference in New Issue
Block a user