diff --git a/bridge/bridge_1c.py b/bridge/bridge_1c.py index 2824e5d..c849bf9 100644 --- a/bridge/bridge_1c.py +++ b/bridge/bridge_1c.py @@ -113,7 +113,7 @@ class BridgeHandler(BaseHTTPRequestHandler): got = self.headers.get("X-Api-Key", "") if not got: got = self.headers.get("Authorization", "").removeprefix("Bearer ").strip() - return bool(got) and hmac.compare_digest(got, BRIDGE_API_KEY) + return bool(got) and hmac.compare_digest(got.encode("utf-8"), BRIDGE_API_KEY.encode("utf-8")) def _require_auth(self): if self._authorized(): @@ -527,8 +527,13 @@ class BridgeHandler(BaseHTTPRequestHandler): self.send_json(400, {"code": "bad_request", "message": "Invalid JSON"}) return + if not isinstance(req_json, dict): + self.send_json(400, {"code": "bad_request", "message": "JSON object required"}) + return + if path == "/appointments": doctor_id = req_json.get("doctor_id", "") + bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200) branch_id = req_json.get("branch_id", "") patient_name = req_json.get("patient_name", "") patient_phone = req_json.get("patient_phone", "") @@ -711,11 +716,23 @@ class BridgeHandler(BaseHTTPRequestHandler): Попытка Док.БИТ_ИсточникИнформации = Ист; Исключение КонецПопытки; КонецЕсли; + Если ЗначениеЗаполнено("{bitrix_lead_id}") Тогда + Попытка + Док.бит_Б24_СозданИзБитрикс24 = Истина; + Док.бит_Б24_БитриксИД = "{bitrix_lead_id}"; + Док.бит_НомерКлиентаБ24 = "{bitrix_lead_id}"; + Исключение + КонецПопытки; + КонецЕсли; + Док.Записать(РежимЗаписиДокумента.Запись); ID = НРег(XMLСтрока(Док.Ссылка)); + ID_B64 = Base64Строка(ПолучитьДвоичныеДанныеИзСтроки(ID)); Ответ = Новый Структура; Ответ.Вставить("appointment_id", ID); + Ответ.Вставить("appointment_id_b64", ID_B64); + Ответ.Вставить("number", Док.Номер); Ответ.Вставить("status", "booked"); Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); @@ -731,12 +748,242 @@ class BridgeHandler(BaseHTTPRequestHandler): КонецПопытки; """ data, err = call_1c(code) + if isinstance(data, dict) and "appointment_id_b64" in data: + import base64 + try: + data["appointment_id"] = base64.b64decode(data["appointment_id_b64"]).decode("utf-8").strip() + except Exception: + pass if err or (isinstance(data, str) and data.startswith("ОШИБКА")): self.send_json(500, {"code": "booking_failed", "message": err or data}) else: self.send_json(201, data) return + if path == "/appointments/link-bitrix": + # Legacy linking supports UUIDs and document numbers: escape both. + appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200) + bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200) + if not appointment_id or not bitrix_lead_id: + self.send_json(400, {"code": "bad_request", "message": "appointment_id and bitrix_lead_id are required"}) + return + + code = f""" +Попытка + ДокСсылка = Неопределено; + Попытка + ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}")); + Исключение + КонецПопытки; + + Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда + Попытка + ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата()); + Исключение + КонецПопытки; + КонецЕсли; + + Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда + Запрос = Новый Запрос; + Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ"; + Запрос.УстановитьПараметр("Номер", "{appointment_id}"); + Выб = Запрос.Выполнить().Выбрать(); + Если Выб.Следующий() Тогда + ДокСсылка = Выб.Ссылка; + КонецЕсли; + КонецЕсли; + + Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда + Запрос = Новый Запрос; + Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ"; + Выб = Запрос.Выполнить().Выбрать(); + Пока Выб.Следующий() Цикл + СтрGUID = НРег(XMLСтрока(Выб.Ссылка)); + Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда + ДокСсылка = Выб.Ссылка; + Прервать; + КонецЕсли; + КонецЦикла; + КонецЕсли; + + Если ЗначениеЗаполнено(ДокСсылка) Тогда + ДокОбъект = ДокСсылка.ПолучитьОбъект(); + Если ДокОбъект <> Неопределено Тогда + ДокОбъект.бит_Б24_СозданИзБитрикс24 = Истина; + ДокОбъект.бит_Б24_БитриксИД = "{bitrix_lead_id}"; + ДокОбъект.бит_НомерКлиентаБ24 = "{bitrix_lead_id}"; + ДокОбъект.Записать(РежимЗаписиДокумента.Запись); + Результат = "OK"; + Иначе + Результат = "NOT_FOUND"; + КонецЕсли; + Иначе + Результат = "NOT_FOUND"; + КонецЕсли; +Исключение + Результат = "ОШИБКА: " + ОписаниеОшибки(); +КонецПопытки; +""" + data, err = call_1c(code) + if err or (isinstance(data, str) and data.startswith("ОШИБКА")): + self.send_json(500, {"code": "link_failed", "message": err or data}) + elif data == "NOT_FOUND": + self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"}) + else: + self.send_json(200, {"status": "ok", "message": "Связано успешно"}) + return + + if path == "/appointments/link-crm": + appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200) + crm_deal_id = bsl_text(str(req_json.get("crm_deal_id", "")).strip(), 200) + if not appointment_id or not crm_deal_id: + self.send_json(400, {"code": "bad_request", "message": "appointment_id and crm_deal_id are required"}) + return + + code = f""" +Попытка + ДокСсылка = Неопределено; + Попытка + ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}")); + Исключение + КонецПопытки; + + Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда + Попытка + ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата()); + Исключение + КонецПопытки; + КонецЕсли; + + Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда + Запрос = Новый Запрос; + Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ"; + Запрос.УстановитьПараметр("Номер", "{appointment_id}"); + Выб = Запрос.Выполнить().Выбрать(); + Если Выб.Следующий() Тогда + ДокСсылка = Выб.Ссылка; + КонецЕсли; + КонецЕсли; + + Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда + Запрос = Новый Запрос; + Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ"; + Выб = Запрос.Выполнить().Выбрать(); + Пока Выб.Следующий() Цикл + СтрGUID = НРег(XMLСтрока(Выб.Ссылка)); + Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда + ДокСсылка = Выб.Ссылка; + Прервать; + КонецЕсли; + КонецЦикла; + КонецЕсли; + + Если ЗначениеЗаполнено(ДокСсылка) Тогда + ДокОбъект = ДокСсылка.ПолучитьОбъект(); + Если ДокОбъект <> Неопределено Тогда + Если Найти(ДокОбъект.Примечание, "{crm_deal_id}") = 0 Тогда + ДокОбъект.Примечание = СокрЛП(ДокОбъект.Примечание) + " [CRM: {crm_deal_id}]"; + КонецЕсли; + ДокОбъект.Записать(РежимЗаписиДокумента.Запись); + Результат = "OK"; + Иначе + Результат = "NOT_FOUND"; + КонецЕсли; + Иначе + Результат = "NOT_FOUND"; + КонецЕсли; +Исключение + Результат = "ОШИБКА: " + ОписаниеОшибки(); +КонецПопытки; +""" + data, err = call_1c(code) + if err or (isinstance(data, str) and data.startswith("ОШИБКА")): + self.send_json(500, {"code": "link_failed", "message": err or data}) + elif data == "NOT_FOUND": + self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"}) + else: + self.send_json(200, {"status": "ok", "message": "Связано успешно с CRM"}) + return + + self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"}) + + def do_DELETE(self): + parsed = urllib.parse.urlparse(self.path) + path = parsed.path.rstrip("/") + + if not self._require_auth(): + return + + parts = path.split("/") + if len(parts) == 3 and parts[1] == "appointments": + appointment_id = bsl_text(urllib.parse.unquote(parts[2]).strip(), 200) + code = f""" +Попытка + ДокСсылка = Неопределено; + Попытка + ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}")); + Исключение + КонецПопытки; + + Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда + Попытка + ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата()); + Исключение + КонецПопытки; + КонецЕсли; + + Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда + Запрос = Новый Запрос; + Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ"; + Запрос.УстановитьПараметр("Номер", "{appointment_id}"); + Выб = Запрос.Выполнить().Выбрать(); + Если Выб.Следующий() Тогда + ДокСсылка = Выб.Ссылка; + КонецЕсли; + КонецЕсли; + + Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда + Запрос = Новый Запрос; + Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ"; + Выб = Запрос.Выполнить().Выбрать(); + Пока Выб.Следующий() Цикл + СтрGUID = НРег(XMLСтрока(Выб.Ссылка)); + Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда + ДокСсылка = Выб.Ссылка; + Прервать; + КонецЕсли; + КонецЦикла; + КонецЕсли; + + Если ЗначениеЗаполнено(ДокСсылка) Тогда + ДокОбъект = ДокСсылка.ПолучитьОбъект(); + Если ДокОбъект <> Неопределено Тогда + Попытка + ДокОбъект.Состояние = Справочники.ВидыСостоянийЗаявок.Отменена; + Исключение + КонецПопытки; + ДокОбъект.ПометкаУдаления = Истина; + ДокОбъект.Записать(РежимЗаписиДокумента.Запись); + Результат = "OK"; + Иначе + Результат = "NOT_FOUND"; + КонецЕсли; + Иначе + Результат = "NOT_FOUND"; + КонецЕсли; +Исключение + Результат = "ОШИБКА: " + ОписаниеОшибки(); +КонецПопытки; +""" + data, err = call_1c(code) + if err or (isinstance(data, str) and data.startswith("ОШИБКА")): + self.send_json(500, {"code": "cancel_failed", "message": err or data}) + elif data == "NOT_FOUND": + self.send_json(404, {"code": "not_found", "message": "Заявка в 1С не найдена"}) + else: + self.send_json(200, {"status": "cancelled", "message": "Запись отменена в 1С"}) + return + self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"}) def run(): diff --git a/bridge/test_bridge_extra.py b/bridge/test_bridge_extra.py index d255280..92ada55 100644 --- a/bridge/test_bridge_extra.py +++ b/bridge/test_bridge_extra.py @@ -26,11 +26,12 @@ class ExtraSecurityTests(unittest.TestCase): def setUp(self): bridge.call_1c.reset_mock() - def post(self, body): + def post(self, body, path="/appointments", headers=None, method=None): request = urllib.request.Request( - f"http://127.0.0.1:{server.server_port}/appointments", + f"http://127.0.0.1:{server.server_port}" + path, data=json.dumps(body).encode(), - headers={"X-Api-Key": "test-only"}, + headers={"X-Api-Key": "test-only"} if headers is None else headers, + method=method, ) try: with urllib.request.urlopen(request, timeout=5) as response: @@ -71,6 +72,36 @@ class ExtraSecurityTests(unittest.TestCase): self.assertIn('Представление = "+7999";', code) self.assertIn('"Онлайн-запись: Test""comment";', code) + def test_non_object_json_rejected(self): + self.assertEqual(self.post([]), 400) + bridge.call_1c.assert_not_called() + + def test_non_ascii_bad_key_rejected(self): + self.assertEqual(self.post({}, headers={"X-Api-Key": "é"}), 401) + bridge.call_1c.assert_not_called() + + def test_bitrix_link_preserved_and_escaped(self): + self.assertEqual(self.post({"appointment_id": 'id"value', "bitrix_lead_id": 'lead"value'}, "/appointments/link-bitrix"), 200) + code = bridge.call_1c.call_args.args[0] + self.assertIn('БитриксИД = "lead""value";', code) + self.assertIn('УникальныйИдентификатор("id""value")', code) + + def test_crm_link_preserved_and_escaped(self): + self.assertEqual(self.post({"appointment_id": "00001234", "crm_deal_id": 'deal"value'}, "/appointments/link-crm"), 200) + code = bridge.call_1c.call_args.args[0] + self.assertIn('[CRM: deal""value]', code) + self.assertIn('НайтиПоНомеру("00001234",', code) + + def test_delete_requires_auth_before_1c(self): + self.assertEqual(self.post({}, "/appointments/test", headers={}, method="DELETE"), 401) + bridge.call_1c.assert_not_called() + + def test_delete_preserved_and_escaped(self): + self.assertEqual(self.post({}, "/appointments/test%22value", method="DELETE"), 200) + code = bridge.call_1c.call_args.args[0] + self.assertIn('УникальныйИдентификатор("test""value")', code) + self.assertIn('ПометкаУдаления = Истина;', code) + if __name__ == "__main__": try: diff --git a/deploy/SECURITY-20261020.md b/deploy/SECURITY-20261020.md index 6eb2644..fb5e693 100644 --- a/deploy/SECURITY-20261020.md +++ b/deploy/SECURITY-20261020.md @@ -13,7 +13,12 @@ Applied to the bridge only. API and PostgreSQL must not be rebuilt or restarted. - Existing dana-medical_default network is external to Compose: prevents recreation of the live API/DB network. Existing subnet: 172.20.0.0/16. - Runtime Python file owned by UID10001 regardless of build-context permissions. -- 19 supplied smoke assertions and 6 additional isolated security regressions. +- Existing Bitrix/CRM linking, appointment cancellation and response fields are + preserved from the original running source. The package's full replacement + file predates these features; only its targeted patch was applied to the base. +- Legacy linking/cancellation support both UUID and document-number lookup; + their BSL literals are escaped without removing that existing compatibility. +- 19 supplied smoke assertions and 12 additional isolated security regressions. ## Host firewall: prepared, not installed