From c1c4ba2ecf743ff12779e58cbe2b9c4c3e048ee1 Mon Sep 17 00:00:00 2001 From: Hamza Ayed Date: Sat, 3 Oct 2026 14:35:31 +0300 Subject: [PATCH] Restrict local API to loopback clients --- SovereignAI-Starter/README.md | 1 + SovereignAI-Starter/ROADMAP.md | 1 + SovereignAI-Starter/app/main.py | 19 +++++++++++++++++++ SovereignAI-Starter/tests/api_client.py | 6 +++++- SovereignAI-Starter/tests/test_auth.py | 15 +++++++++++++-- SovereignAI-Starter/tests/test_knowledge.py | 8 ++++++-- 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/SovereignAI-Starter/README.md b/SovereignAI-Starter/README.md index cf0ed27..fb013b8 100644 --- a/SovereignAI-Starter/README.md +++ b/SovereignAI-Starter/README.md @@ -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` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة. diff --git a/SovereignAI-Starter/ROADMAP.md b/SovereignAI-Starter/ROADMAP.md index 70b835c..3c1d5a2 100644 --- a/SovereignAI-Starter/ROADMAP.md +++ b/SovereignAI-Starter/ROADMAP.md @@ -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؛ عند تبديل الحساب تمسح الواجهة الحالة المحلية ثم تعيد تحميل المحادثات والمهارات تحت الهوية الجديدة. diff --git a/SovereignAI-Starter/app/main.py b/SovereignAI-Starter/app/main.py index f365513..5fddc79 100644 --- a/SovereignAI-Starter/app/main.py +++ b/SovereignAI-Starter/app/main.py @@ -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: diff --git a/SovereignAI-Starter/tests/api_client.py b/SovereignAI-Starter/tests/api_client.py index b3133f2..c5984b7 100644 --- a/SovereignAI-Starter/tests/api_client.py +++ b/SovereignAI-Starter/tests/api_client.py @@ -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), + ) diff --git a/SovereignAI-Starter/tests/test_auth.py b/SovereignAI-Starter/tests/test_auth.py index f16a7da..72e76f2 100644 --- a/SovereignAI-Starter/tests/test_auth.py +++ b/SovereignAI-Starter/tests/test_auth.py @@ -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", diff --git a/SovereignAI-Starter/tests/test_knowledge.py b/SovereignAI-Starter/tests/test_knowledge.py index b878574..d9b8c9d 100644 --- a/SovereignAI-Starter/tests/test_knowledge.py +++ b/SovereignAI-Starter/tests/test_knowledge.py @@ -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,