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:
drjones
2026-09-20 20:24:07 -07:00
parent d7ce96fba4
commit e18bdb1b41
3 changed files with 81 additions and 18 deletions

80
sms.py
View File

@@ -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 "

View File

@@ -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 = '<div class="from">FROM +' + (msg.from_number||'?') + '</div>' +
'<div class="body"></div>' +
(msg.detected_code ? '<span class="code-tag">' + msg.detected_code + '</span>' : '');
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);
});
}

View File

@@ -10,6 +10,7 @@
<div style="background:rgba(20,28,40,.6);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px"><div style="font-size:.7rem;color:#94a3b8;letter-spacing:.1em">SESSIONS</div><div style="font-size:1.6rem;color:#fff">{{ stats.total_sessions }}</div></div>
<div style="background:rgba(20,28,40,.6);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px"><div style="font-size:.7rem;color:#94a3b8;letter-spacing:.1em">SMS RECEIVED</div><div style="font-size:1.6rem;color:#fff">{{ stats.sms_received }}</div></div>
<div style="background:rgba(20,28,40,.6);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px"><div style="font-size:.7rem;color:#94a3b8;letter-spacing:.1em">REVENUE</div><div style="font-size:1.6rem;color:#4ade80">{{ stats.revenue_sats }} sats</div></div>
<div style="background:rgba(20,28,40,.6);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px"><div style="font-size:.7rem;color:#94a3b8;letter-spacing:.1em">PROVIDER COST</div><div style="font-size:1.6rem;color:#f59e0b">${{ '%.2f'|format(stats.provider_cost_usd) }}</div></div>
</div>
<h2 style="font-size:1.2rem;margin:20px 0 10px">Pricing (sats)</h2>