hatimqman commited on
Commit
d60fa18
·
1 Parent(s): 585d698

تحصين خادم QPS: فحص أصل صريح، سقف حجم/طابور، مهلة اتصال، تحقّق مدخلات، إخفاء أثر الاستثناء

Browse files

اكتُشِفت في تدقيقٍ أمنيّ: النقطة كانت مفتوحةً بالكامل (CORS *، بلا فحص أصل، بلا سقف حجمٍ)
فيستطيع أيّ موقعٍ استهلاك رصيد GPU مباشرةً، وكانت رسائل الخطأ تُسرّب تتبّع استثناءٍ داخليّ.

Files changed (2) hide show
  1. app.py +96 -18
  2. handler.py +5 -1
app.py CHANGED
@@ -9,6 +9,7 @@ from __future__ import annotations
9
 
10
  import json
11
  import os
 
12
  import socket
13
  import threading
14
  import time
@@ -16,6 +17,20 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
 
17
  import numpy as np
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  try:
20
  import torch as _torch
21
  _torch.set_num_threads(os.cpu_count() or 4)
@@ -44,18 +59,31 @@ def _json_default(o):
44
 
45
 
46
  class Handler(BaseHTTPRequestHandler):
 
 
 
 
47
  def log_message(self, *a):
48
  pass
49
 
50
- def _cors(self):
51
- self.send_header("Access-Control-Allow-Origin", "*")
 
 
 
 
 
 
 
 
 
52
  self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
53
  self.send_header("Access-Control-Allow-Headers", "Content-Type")
54
 
55
- def _send(self, code, obj):
56
  body = json.dumps(obj, default=_json_default, ensure_ascii=False).encode("utf-8")
57
  self.send_response(code)
58
- self._cors()
59
  self.send_header("Content-Type", "application/json; charset=utf-8")
60
  self.send_header("Content-Length", str(len(body)))
61
  self.end_headers()
@@ -63,33 +91,82 @@ class Handler(BaseHTTPRequestHandler):
63
 
64
  def do_OPTIONS(self):
65
  self.send_response(204)
66
- self._cors()
67
  self.send_header("Content-Length", "0")
68
  self.end_headers()
69
 
70
  def do_GET(self):
71
- self._send(200, {"ok": True, "service": "al-moratel-qps", "routes": ["/analyze"]})
72
 
73
  def do_POST(self):
 
74
  if self.path not in ("/", "/analyze"):
75
- self._send(404, {"error": "not_found"})
 
 
 
76
  return
77
- length = int(self.headers.get("Content-Length", "0") or 0)
78
- raw = self.rfile.read(length) if length else b""
 
 
 
 
 
 
79
  try:
80
  data = json.loads(raw) if raw else {}
81
  except Exception:
82
- self._send(400, {"error": "bad_json"})
83
  return
84
  pcm = data.get("pcm")
85
  if not pcm or not isinstance(pcm, str):
86
- self._send(400, {"error": "missing_pcm"})
87
  return
88
- inner = {"pcm": pcm, "surah": data.get("surah")}
89
- if data.get("ayahs") is not None:
90
- inner["ayahs"] = data["ayahs"]
91
- if data.get("ayah") is not None:
92
- inner["ayah"] = data["ayah"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  inner["sifat"] = bool(data.get("sifat", False))
94
  # عدد المستخدمين غير محدود: بدل رفض الطلب الثاني فورًا (503 busy) نُصفّه في طابورٍ وننتظر
95
  # دورَه — النموذج نفسه ما زال يعالج تحليلًا واحدًا في كلّ لحظة (GPU واحد)، لكن لا أحد يُرفَض أو
@@ -99,13 +176,14 @@ class Handler(BaseHTTPRequestHandler):
99
  try:
100
  res = _handler({"inputs": inner})
101
  except Exception as e:
102
- self._send(500, {"error": str(e)[:300]})
 
103
  return
104
  finally:
105
  _lock.release()
106
  print(f"[al-moratel-qps] /analyze surah={inner.get('surah')} "
107
  f"ayahs={inner.get('ayahs') or inner.get('ayah')} ({time.time() - t:.1f}ث)", flush=True)
108
- self._send(200, res)
109
 
110
 
111
  class DualStackServer(ThreadingHTTPServer):
 
9
 
10
  import json
11
  import os
12
+ import re
13
  import socket
14
  import threading
15
  import time
 
17
 
18
  import numpy as np
19
 
20
+ # ── الحماية (أُضيفت بعد تدقيقٍ أمنيّ 2026-08-13): هذه النقطة كانت مفتوحةً بالكامل (CORS *، بلا
21
+ # فحص أصل، بلا سقف حجمٍ، بلا مهلة) — أيّ موقعٍ يقدر يستدعيها مباشرةً ويستهلك رصيد GPU، أو يفتح
22
+ # اتصالاتٍ بطيئة/ضخمة يُنهك الخيط الوحيد المُسلسَل بالقفل. نطابق نمط الحماية المُطبَّق فعلًا على
23
+ # دالّة Whisper الوسيطة (client/functions/api/transcribe.js) لكن كقائمة أصولٍ صريحة (لا يوجد
24
+ # "نفس الأصل" ذاتيّ هنا لأنّ هذا خادمٌ منفصلٌ على نطاق hf.space لا يخدم الصفحة نفسها).
25
+ ALLOWED_ORIGIN_RE = re.compile(
26
+ r"^https://([a-z0-9-]+\.)?al-moratel\.pages\.dev$" # الإنتاج + معاينات الفروع المُجزَّأة
27
+ r"|^capacitor://localhost$" # تطبيق iOS (Capacitor WKWebView)
28
+ r"|^ionic://localhost$" # احتياطٌ لبعض إعدادات Capacitor القديمة
29
+ r"|^https?://(localhost|127\.0\.0\.1)(:\d+)?$" # تطويرٌ محليّ (XAMPP)
30
+ )
31
+ MAX_BODY_BYTES = 8 * 1024 * 1024 # أطول آيةٍ واقعيّة (~٦٠ث صوتٍ خامّ base64) لا تقارب هذا الحدّ
32
+ MAX_AYAHS = 50 # سقف عدد الآيات في طلبٍ واحد (تحليل مقطعٍ متعدّد الآيات)
33
+
34
  try:
35
  import torch as _torch
36
  _torch.set_num_threads(os.cpu_count() or 4)
 
59
 
60
 
61
  class Handler(BaseHTTPRequestHandler):
62
+ # مهلة قراءة الاتّصال (ث) — تمنع طلبًا بجسمٍ بطيءٍ/ناقصٍ عمدًا من حجز خيطٍ إلى الأبد
63
+ # (كلّ طلبٍ يُخدَم في خيطه الخاصّ عبر ThreadingHTTPServer، لكن الخيوط ليست بلا حدود).
64
+ timeout = 30
65
+
66
  def log_message(self, *a):
67
  pass
68
 
69
+ def _allowed_origin(self):
70
+ origin = self.headers.get("Origin") or self.headers.get("Referer") or ""
71
+ return origin if ALLOWED_ORIGIN_RE.match(origin) else None
72
+
73
+ def _cors(self, origin=None):
74
+ # نعكس الأصل المطابَق فقط (لا "*") — طلبٌ من أصلٍ غير مسموحٍ لا يحصل على ترويسة السماح
75
+ # فيرفضه المتصفّح تلقائيًّا حتى لو نجح المزيّف عبر أدواتٍ لا تحترم CORS (curl مثلًا)، حيث
76
+ # يبقى فحص do_POST الصريح أدناه هو خطّ الدفاع الحقيقيّ (CORS يحمي متصفّحًا لا نصًّا برمجيًّا).
77
+ if origin:
78
+ self.send_header("Access-Control-Allow-Origin", origin)
79
+ self.send_header("Vary", "Origin")
80
  self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
81
  self.send_header("Access-Control-Allow-Headers", "Content-Type")
82
 
83
+ def _send(self, code, obj, origin=None):
84
  body = json.dumps(obj, default=_json_default, ensure_ascii=False).encode("utf-8")
85
  self.send_response(code)
86
+ self._cors(origin)
87
  self.send_header("Content-Type", "application/json; charset=utf-8")
88
  self.send_header("Content-Length", str(len(body)))
89
  self.end_headers()
 
91
 
92
  def do_OPTIONS(self):
93
  self.send_response(204)
94
+ self._cors(self._allowed_origin())
95
  self.send_header("Content-Length", "0")
96
  self.end_headers()
97
 
98
  def do_GET(self):
99
+ self._send(200, {"ok": True, "service": "al-moratel-qps", "routes": ["/analyze"]}, self._allowed_origin())
100
 
101
  def do_POST(self):
102
+ origin = self._allowed_origin()
103
  if self.path not in ("/", "/analyze"):
104
+ self._send(404, {"error": "not_found"}, origin)
105
+ return
106
+ if not origin:
107
+ self._send(403, {"error": "forbidden_origin"}, None)
108
  return
109
+ declared = int(self.headers.get("Content-Length", "0") or 0)
110
+ if declared <= 0:
111
+ self._send(400, {"error": "missing_body"}, origin)
112
+ return
113
+ if declared > MAX_BODY_BYTES:
114
+ self._send(413, {"error": "payload_too_large"}, origin)
115
+ return
116
+ raw = self.rfile.read(declared)
117
  try:
118
  data = json.loads(raw) if raw else {}
119
  except Exception:
120
+ self._send(400, {"error": "bad_json"}, origin)
121
  return
122
  pcm = data.get("pcm")
123
  if not pcm or not isinstance(pcm, str):
124
+ self._send(400, {"error": "missing_pcm"}, origin)
125
  return
126
+
127
+ # تحقّق حدود surah/ayah/ayahs قبل استدعاء المحلّل — يمنع تسريب أثر استثناءٍ داخليّ على
128
+ # مدخلاتٍ خارج المدى (كان يحدث سابقًا)، ويمنع طلبًا بمصفوفة ayahs ضخمة يُنهك الخيط
129
+ # المُسلسَل الوحيد (GPU واحد) على حساب كلّ مستخدمٍ آخر ينتظر في الطابور.
130
+ try:
131
+ surah = int(data.get("surah"))
132
+ except (TypeError, ValueError):
133
+ self._send(400, {"error": "bad_surah"}, origin)
134
+ return
135
+ if not (1 <= surah <= 114):
136
+ self._send(400, {"error": "surah_out_of_range"}, origin)
137
+ return
138
+ ayahs_in = data.get("ayahs")
139
+ ayah_in = data.get("ayah")
140
+ if ayahs_in is not None:
141
+ if not isinstance(ayahs_in, list) or not ayahs_in or len(ayahs_in) > MAX_AYAHS:
142
+ self._send(400, {"error": "bad_ayahs"}, origin)
143
+ return
144
+ try:
145
+ ayahs_in = [int(a) for a in ayahs_in]
146
+ except (TypeError, ValueError):
147
+ self._send(400, {"error": "bad_ayahs"}, origin)
148
+ return
149
+ if any(not (1 <= a <= 300) for a in ayahs_in):
150
+ self._send(400, {"error": "ayah_out_of_range"}, origin)
151
+ return
152
+ elif ayah_in is not None:
153
+ try:
154
+ ayah_in = int(ayah_in)
155
+ except (TypeError, ValueError):
156
+ self._send(400, {"error": "bad_ayah"}, origin)
157
+ return
158
+ if not (1 <= ayah_in <= 300):
159
+ self._send(400, {"error": "ayah_out_of_range"}, origin)
160
+ return
161
+ else:
162
+ self._send(400, {"error": "missing_ayah_or_ayahs"}, origin)
163
+ return
164
+
165
+ inner = {"pcm": pcm, "surah": surah}
166
+ if ayahs_in is not None:
167
+ inner["ayahs"] = ayahs_in
168
+ if ayah_in is not None:
169
+ inner["ayah"] = ayah_in
170
  inner["sifat"] = bool(data.get("sifat", False))
171
  # عدد المستخدمين غير محدود: بدل رفض الطلب الثاني فورًا (503 busy) نُصفّه في طابورٍ وننتظر
172
  # دورَه — النموذج نفسه ما زال يعالج تحليلًا واحدًا في كلّ لحظة (GPU واحد)، لكن لا أحد يُرفَض أو
 
176
  try:
177
  res = _handler({"inputs": inner})
178
  except Exception as e:
179
+ print(f"[al-moratel-qps] استثناءٌ غير متوقَّع: {e}", flush=True)
180
+ self._send(500, {"error": "processing_failed"}, origin)
181
  return
182
  finally:
183
  _lock.release()
184
  print(f"[al-moratel-qps] /analyze surah={inner.get('surah')} "
185
  f"ayahs={inner.get('ayahs') or inner.get('ayah')} ({time.time() - t:.1f}ث)", flush=True)
186
+ self._send(200, res, origin)
187
 
188
 
189
  class DualStackServer(ThreadingHTTPServer):
handler.py CHANGED
@@ -281,4 +281,8 @@ class EndpointHandler:
281
  return out
282
  except Exception as e:
283
  import traceback
284
- return {"error": str(e), "trace": traceback.format_exc()[-800:]}
 
 
 
 
 
281
  return out
282
  except Exception as e:
283
  import traceback
284
+ # لا نُعيد أثر الاستثناء (مسارات ملفّاتٍ داخليّة) للمتصل — نطبعه في سجلّ الخادم فقط
285
+ # ونُرجع رسالة عامّة نظيفة. (اكتُشِف هذا في تدقيقٍ أمنيّ: طلبٌ فاسدٌ/خارج المدى كان
286
+ # يُرجع تتبّعًا كاملًا يكشف بنية الملفّات الداخليّة.)
287
+ print(f"[al-moratel-qps] خطأ: {e}\n{traceback.format_exc()}", flush=True)
288
+ return {"error": "processing_failed"}