From e18bdb1b415cf88feec8eb7c06aa1f0cc24b65e7 Mon Sep 17 00:00:00 2001 From: drjones <> Date: Sun, 20 Sep 2026 20:24:07 -0700 Subject: [PATCH] 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) --- sms.py | 80 +++++++++++++++++++++++++++++++++------- templates/sms.html | 18 +++++++-- templates/sms_admin.html | 1 + 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/sms.py b/sms.py index f8e4d0c..73e64f7 100644 --- a/sms.py +++ b/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 " diff --git a/templates/sms.html b/templates/sms.html index 9f4e5db..d5c7835 100644 --- a/templates/sms.html +++ b/templates/sms.html @@ -155,10 +155,20 @@ select{background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.12);color:#f d.messages.forEach(msg => { const div = document.createElement('div'); div.className = 'sms-msg'; - div.innerHTML = '
FROM +' + (msg.from_number||'?') + '
' + - '
' + - (msg.detected_code ? '' + msg.detected_code + '' : ''); - div.querySelector('.body').textContent = msg.body; + const fromEl = document.createElement('div'); + fromEl.className = 'from'; + fromEl.textContent = 'FROM +' + (msg.from_number || '?'); + const bodyEl = document.createElement('div'); + bodyEl.className = 'body'; + bodyEl.textContent = msg.body || ''; + div.appendChild(fromEl); + div.appendChild(bodyEl); + if (msg.detected_code){ + const codeEl = document.createElement('span'); + codeEl.className = 'code-tag'; + codeEl.textContent = msg.detected_code; + div.appendChild(codeEl); + } mEl.appendChild(div); }); } diff --git a/templates/sms_admin.html b/templates/sms_admin.html index dfabaaf..772f02a 100644 --- a/templates/sms_admin.html +++ b/templates/sms_admin.html @@ -10,6 +10,7 @@
SESSIONS
{{ stats.total_sessions }}
SMS RECEIVED
{{ stats.sms_received }}
REVENUE
{{ stats.revenue_sats }} sats
+
PROVIDER COST
${{ '%.2f'|format(stats.provider_cost_usd) }}

Pricing (sats)