Restrict local API to loopback clients
This commit is contained in:
@@ -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` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة.
|
||||
|
||||
@@ -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؛ عند تبديل الحساب تمسح الواجهة الحالة المحلية ثم تعيد تحميل المحادثات والمهارات تحت الهوية الجديدة.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user