fix(security): preserve and harden CRM links and appointment cancellation
This commit is contained in:
parent
050dc30d92
commit
157dfbf186
@ -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():
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user