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
|
# hard-cap at 1200s (20 min) regardless of what's requested. Longer
|
||||||
# sessions require a re-rent loop (not yet implemented).
|
# 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
|
# Schema
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -156,7 +159,8 @@ CREATE TABLE IF NOT EXISTS sms_sessions (
|
|||||||
provider_cost TEXT,
|
provider_cost TEXT,
|
||||||
created_at INTEGER,
|
created_at INTEGER,
|
||||||
expires_at INTEGER,
|
expires_at INTEGER,
|
||||||
sms_received_at INTEGER
|
sms_received_at INTEGER,
|
||||||
|
last_check INTEGER
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS sms_messages (
|
CREATE TABLE IF NOT EXISTS sms_messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -189,6 +193,10 @@ CREATE TABLE IF NOT EXISTS sms_services_cache (
|
|||||||
def init_sms():
|
def init_sms():
|
||||||
d = db.get_db()
|
d = db.get_db()
|
||||||
d.executescript(SCHEMA)
|
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:
|
if d.execute("SELECT COUNT(*) c FROM sms_pricing").fetchone()["c"] == 0:
|
||||||
for p in DEFAULT_PRICING:
|
for p in DEFAULT_PRICING:
|
||||||
d.execute("INSERT INTO sms_pricing (duration_min, price_sats, active) VALUES (?,?,1)",
|
d.execute("INSERT INTO sms_pricing (duration_min, price_sats, active) VALUES (?,?,1)",
|
||||||
@@ -270,6 +278,27 @@ def _session_messages(session_id):
|
|||||||
return rows
|
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):
|
def _service_name(service_id):
|
||||||
for sid, name in POPULAR_SERVICES:
|
for sid, name in POPULAR_SERVICES:
|
||||||
if sid == service_id:
|
if sid == service_id:
|
||||||
@@ -332,14 +361,28 @@ def _all_services():
|
|||||||
|
|
||||||
|
|
||||||
def _extract_sms(chk):
|
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):
|
if not isinstance(chk, dict):
|
||||||
return None, None
|
return None, None
|
||||||
|
status = chk.get("status")
|
||||||
sms = chk.get("sms")
|
sms = chk.get("sms")
|
||||||
if isinstance(sms, dict) and sms.get("text"):
|
if isinstance(sms, dict):
|
||||||
return sms["text"], (sms.get("number") or sms.get("sender") or "")
|
body = (sms.get("text") or sms.get("body") or sms.get("message")
|
||||||
if chk.get("text"):
|
or sms.get("content") or "")
|
||||||
return chk["text"], (chk.get("number") or chk.get("sender") 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
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -393,29 +436,37 @@ def init_sms_app(app, login_required, current_user):
|
|||||||
if price is None:
|
if price is None:
|
||||||
flash("Invalid duration.", "warn")
|
flash("Invalid duration.", "warn")
|
||||||
return redirect(url_for("sms_home"))
|
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)
|
country = next((c for c in _enabled_countries() if c["id"] == country_id), None)
|
||||||
if not country:
|
if not country:
|
||||||
flash("Country unavailable.", "warn")
|
flash("Country unavailable.", "warn")
|
||||||
return redirect(url_for("sms_home"))
|
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:
|
try:
|
||||||
res = PROVIDER.provision_number(service_id, country_id, min(duration, MAX_RENTAL_MIN))
|
res = PROVIDER.provision_number(service_id, country_id, min(duration, MAX_RENTAL_MIN))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
db.add_transaction(u["id"], price, "sms_refund", ref)
|
||||||
flash(f"Provider error: {e}", "warn")
|
flash(f"Provider error: {e}", "warn")
|
||||||
return redirect(url_for("sms_home"))
|
return redirect(url_for("sms_home"))
|
||||||
|
|
||||||
if not res.get("success"):
|
if not res.get("success"):
|
||||||
msg = (res.get("errors") or [{}])[0].get("message") or res.get("message") or "No numbers available."
|
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")
|
flash(f"Number unavailable: {msg}", "warn")
|
||||||
return redirect(url_for("sms_home"))
|
return redirect(url_for("sms_home"))
|
||||||
|
|
||||||
order_id = res.get("order_id") or res.get("orderid")
|
order_id = res.get("order_id") or res.get("orderid")
|
||||||
number = res.get("number") or res.get("phonenumber")
|
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)
|
service_name = _service_name(service_id)
|
||||||
|
|
||||||
d = db.get_db()
|
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, "
|
"country_name, country_cc, provider_order_id, number, status, price_sats, "
|
||||||
"provider_cost, created_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
"provider_cost, created_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(u["id"], service_id, service_name, country_id, country["name"], country["cc"],
|
(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))
|
int(time.time()) + duration * 60))
|
||||||
d.commit()
|
d.commit()
|
||||||
d.close()
|
d.close()
|
||||||
db.add_transaction(u["id"], -price, "sms", order_id)
|
|
||||||
flash(f"Number {number} reserved for {duration} min.", "ok")
|
flash(f"Number {number} reserved for {duration} min.", "ok")
|
||||||
return redirect(url_for("sms_home"))
|
return redirect(url_for("sms_home"))
|
||||||
|
|
||||||
@@ -454,7 +504,9 @@ def init_sms_app(app, login_required, current_user):
|
|||||||
d.commit()
|
d.commit()
|
||||||
sess["status"] = "expired"
|
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:
|
try:
|
||||||
chk = PROVIDER.check_sms(sess["provider_order_id"])
|
chk = PROVIDER.check_sms(sess["provider_order_id"])
|
||||||
except Exception:
|
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"],
|
"total_sessions": d.execute("SELECT COUNT(*) c FROM sms_sessions").fetchone()["c"],
|
||||||
"sms_received": d.execute("SELECT COUNT(*) c FROM sms_messages").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"],
|
"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(
|
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 "
|
"SELECT s.*, u.username FROM sms_sessions s LEFT JOIN users u ON u.id=s.user_id "
|
||||||
|
|||||||
@@ -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 => {
|
d.messages.forEach(msg => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'sms-msg';
|
div.className = 'sms-msg';
|
||||||
div.innerHTML = '<div class="from">FROM +' + (msg.from_number||'?') + '</div>' +
|
const fromEl = document.createElement('div');
|
||||||
'<div class="body"></div>' +
|
fromEl.className = 'from';
|
||||||
(msg.detected_code ? '<span class="code-tag">' + msg.detected_code + '</span>' : '');
|
fromEl.textContent = 'FROM +' + (msg.from_number || '?');
|
||||||
div.querySelector('.body').textContent = msg.body;
|
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);
|
mEl.appendChild(div);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">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">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">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>
|
</div>
|
||||||
|
|
||||||
<h2 style="font-size:1.2rem;margin:20px 0 10px">Pricing (sats)</h2>
|
<h2 style="font-size:1.2rem;margin:20px 0 10px">Pricing (sats)</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user