fix(security): preserve and harden CRM links and appointment cancellation

This commit is contained in:
Dana Medical Developer 2026-10-20 08:49:14 +00:00
parent 050dc30d92
commit 157dfbf186
3 changed files with 288 additions and 5 deletions

View File

@ -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():

View File

@ -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:

View File

@ -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