Restrict local API to loopback clients

This commit is contained in:
Hamza Ayed
2026-10-03 14:35:31 +03:00
parent d57efbdca3
commit c1c4ba2ecf
6 changed files with 45 additions and 5 deletions
+1
View File
@@ -168,6 +168,7 @@ python scripts/eval_knowledge_retrieval.py --dataset evals/knowledge_retrieval_p
- بناء Windows يتطلب مكوّن C++ ATL ضمن Visual Studio Build Tools بسبب إضافة `flutter_secure_storage_windows`؛ ATL مثبت الآن ونجح بناء Debug على هذا الجهاز. على Android الحد الأدنى صار API 23.
- كل عمليات `/v1/*` الخاصة تتطلب الآن Bearer session؛ الاستثناءات العامة هي الصحة وقائمة النماذج وتسجيل/دخول الحساب وإنشاء الجلسة المحلية. عميل Flutter يرسل الجلسة للمحادثة والوكيل والملفات والمعرفة والبحث والصوت. ملكية سجل تدقيق الوكيل وفهرس المعرفة ومقترحات تعديل الملفات مرتبطة بمعرّف الحساب.
- أضيف حد أولي لمحاولات الدخول وإنشاء الحسابات وطلبات استعادة كلمة المرور في SQLite، لكنه لم يخضع بعد لاختبار ضغط/خلف reverse proxy. يخصص `SOVEREIGNAI_USER_WORKSPACES` مجلدات منفصلة للحسابات ضمن جذور `SOVEREIGNAI_ALLOWED_WORKSPACES`، لكن عملية FastAPI ما زالت ترث صلاحيات نظام التشغيل. أبقِ الخدمة على `127.0.0.1` إلى أن يكتمل العزل على مستوى العملية ويُختبر TLS.
- يرفض FastAPI الآن أي طلب مصدر اتصاله ليس loopback (`127.0.0.1` أو `::1`) بحالة 403، حتى لو شُغّل الخادم خطأً على واجهة شبكة أوسع. هذا حاجز تطبيق إضافي، وليس عزلًا لصلاحيات ملفات العملية؛ لا تعرض الخدمة على الشبكة قبل إكمال العزل وتكوين TLS.
- رسائل الدردشة ترسل إلى Ollama المحلي. التسجيل الصوتي يحوّل إلى نص عبر Groq Whisper؛ لذلك يُرسل الصوت إلى Groq عند الضغط على إيقاف التسجيل.
- مفتاح Groq يجب أن يبقى في متغير البيئة `GROQ_API_KEY` الخاص بخادم FastAPI، ولا يوضع في Flutter أو في ملفات المشروع.
- على جهاز التطوير الحالي حُفظ المتغير في بيئة Windows الخاصة بالمستخدم، وليس في ملف `.env`. يقرأه `start-api.ps1` عند تشغيل الخادم، ويظهر `/health` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة.
+1
View File
@@ -68,6 +68,7 @@
- [x] فرض Bearer على جميع عمليات `/v1` الخاصة وإرسال الجلسة من Flutter للمحادثة/الوكيل/الملفات/المعرفة/البحث والصوت؛ فحص OpenAPI يضمن ألا توجد عملية خاصة بلا HTTP Bearer.
- [x] عزل فهرس المعرفة وسجل التدقيق بمعرّف الحساب، وربط رمز معاينة تعديل الملفات بصاحبها؛ اختبار API أثبت عدم استرجاع حساب لمحتوى فهرسه حساب آخر.
- [x] ربط جذور مساحة العمل بالحساب عبر إعداد مسؤول الخدمة `SOVEREIGNAI_USER_WORKSPACES` (بريد الحساب ← مسارات مخصصة تحت القائمة العامة)؛ يرفض API الحساب الذي لا يملك تخصيصًا، ويرفض المسارات المتداخلة بين الحسابات. اختبار API أكد أن لكل حساب جذره فقط (2026-10-03).
- [x] حاجز loopback داخل FastAPI: يرفض كل طلب peer ليس `127.0.0.1` أو `::1` بحالة 403 ومعرّف طلب، حتى لو رُبط Uvicorn خطأً على عنوان عام. اختبارات العميل المحلي/البعيد والمصادقة والمعرفة نجحت (23 اختبارًا). هذا يحد الوصول الشبكي على مستوى API لكنه لا يعزل ملفات العملية.
- [ ] عزل عملية FastAPI عن ملفات المضيف غير المصرح بها على مستوى نظام التشغيل قبل السماح بعميل شبكي أو خدمة مستضافة؛ القائمة البرمجية وحدها لا تحد صلاحيات العملية نفسها.
- [x] إضافة قائمة سماح على مستوى الخادم عبر `SOVEREIGNAI_ALLOWED_WORKSPACES`؛ يرفض API أي مجلد خارج الجذور المعتمدة. مع `SOVEREIGNAI_USER_WORKSPACES` يطبق الخادم كذلك حدود جذور كل حساب. لا يغني ذلك عن عزل صلاحيات عملية FastAPI على مستوى نظام التشغيل.
- [x] بناء واجهة إنشاء الحساب/الدخول والخروج وربطها بجلسات API؛ عند تبديل الحساب تمسح الواجهة الحالة المحلية ثم تعيد تحميل المحادثات والمهارات تحت الهوية الجديدة.
+19
View File
@@ -138,6 +138,25 @@ async def attach_request_id(request: Request, call_next: Any):
"""Give every response a server-generated correlation ID without logging content."""
request_id = str(uuid4())
request.state.request_id = request_id
client_host = request.client.host if request.client is not None else ""
try:
remote_client = not ipaddress.ip_address(client_host).is_loopback
except ValueError:
remote_client = True
if remote_client:
response = JSONResponse(
status_code=403,
content={
"error": {
"code": "loopback_only",
"message": "هذه النسخة المحلية تقبل الاتصالات من الجهاز نفسه فقط.",
},
"detail": "هذه النسخة المحلية تقبل الاتصالات من الجهاز نفسه فقط.",
"request_id": request_id,
},
headers={"X-Request-ID": request_id},
)
return response
try:
response = await call_next(request)
except Exception:
+5 -1
View File
@@ -9,4 +9,8 @@ from app import auth, database
def authenticated_client(app: FastAPI) -> TestClient:
database.ensure_user(database.LOCAL_USER_ID)
token, _ = auth.issue_session(database.LOCAL_USER_ID)
return TestClient(app, headers={"Authorization": f"Bearer {token}"})
return TestClient(
app,
headers={"Authorization": f"Bearer {token}"},
client=("127.0.0.1", 8000),
)
+13 -2
View File
@@ -20,7 +20,7 @@ from app.main import app, create_local_session
class AuthenticationTests(unittest.TestCase):
def setUp(self) -> None:
self.client = TestClient(app)
self.client = TestClient(app, client=("127.0.0.1", 8000))
self.created_users: list[str] = []
def tearDown(self) -> None:
@@ -321,9 +321,20 @@ class AuthenticationTests(unittest.TestCase):
self.assertEqual(second_own.json()["files"], ["two.md"])
def test_local_bootstrap_is_restricted_to_loopback_clients(self) -> None:
remote = self.client.post("/v1/auth/local-session", json={})
remote_client = TestClient(app, client=("192.0.2.10", 43210))
remote = remote_client.post("/v1/auth/local-session", json={})
self.assertEqual(remote.status_code, 403, remote.text)
def test_service_rejects_non_loopback_clients_even_for_public_endpoints(self) -> None:
remote_client = TestClient(app, client=("198.51.100.23", 51000))
blocked = remote_client.get("/openapi.json")
local = self.client.get("/openapi.json")
self.assertEqual(blocked.status_code, 403, blocked.text)
self.assertEqual(blocked.json()["error"]["code"], "loopback_only")
self.assertTrue(blocked.headers.get("x-request-id"))
self.assertEqual(local.status_code, 200, local.text)
request = Request(
{
"type": "http",
+6 -2
View File
@@ -186,10 +186,14 @@ class KnowledgeIndexTests(unittest.TestCase):
owner_token, _ = auth.issue_session(owner_id)
other_token, _ = auth.issue_session(other_id)
owner_client = TestClient(
app, headers={"Authorization": f"Bearer {owner_token}"}
app,
headers={"Authorization": f"Bearer {owner_token}"},
client=("127.0.0.1", 8000),
)
other_client = TestClient(
app, headers={"Authorization": f"Bearer {other_token}"}
app,
headers={"Authorization": f"Bearer {other_token}"},
client=("127.0.0.1", 8000),
)
with patch.dict(
os.environ,