From 20e5872a75d8cd1808bb0fb8229c865d2ae9fb0b Mon Sep 17 00:00:00 2001 From: chenjintao Date: Mon, 28 Sep 2026 11:54:51 +0800 Subject: [PATCH] feat: allow talkingq OTA domains --- docs/OTA_READY_FACTORY_BASELINE.md | 6 + .../threadx/interface/custom_app/Makefile | 1 + .../interface/custom_app/inc/ota_url_policy.h | 6 + .../threadx/interface/custom_app/mqtt.c | 22 +- .../interface/custom_app/ota_url_policy.c | 189 ++++++++++++++++++ tools/test_ota_url_policy.c | 53 +++++ tools/verify_ota_ready_baseline.py | 8 + 7 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 ql-application/threadx/interface/custom_app/inc/ota_url_policy.h create mode 100644 ql-application/threadx/interface/custom_app/ota_url_policy.c create mode 100644 tools/test_ota_url_policy.c diff --git a/docs/OTA_READY_FACTORY_BASELINE.md b/docs/OTA_READY_FACTORY_BASELINE.md index 64177fd..871acb8 100644 --- a/docs/OTA_READY_FACTORY_BASELINE.md +++ b/docs/OTA_READY_FACTORY_BASELINE.md @@ -236,6 +236,12 @@ ASR1605/CRANELRH,但量产包必须由 `EG800AKCN_11LG` 配置生成。 设备校验顺序:产品、layout、版本序号、URL host、大小、SHA-256,最后才允许设置升级标志。 +当前最小版本保留原服务器 +`http://101.35.224.118:8080/assets/firmware/`,并允许 `talkingq.com` 根域及任意合法 +子域名通过 HTTP/HTTPS 提供 `.bin` 文件,例如 `ota.talkingq.com` 和 +`cdn.cn.talkingq.com`。校验基于解析出的完整 host 边界,`eviltalkingq.com`、 +`talkingq.com.evil.example`、userinfo 绕过、非法端口和路径穿越均会被拒绝。 + 设备状态建议至少包含: - `ACCEPTED`:命令和布局校验通过; diff --git a/ql-application/threadx/interface/custom_app/Makefile b/ql-application/threadx/interface/custom_app/Makefile index 05508c0..b79cab6 100644 --- a/ql-application/threadx/interface/custom_app/Makefile +++ b/ql-application/threadx/interface/custom_app/Makefile @@ -87,6 +87,7 @@ SRC_FILES+= \ battery.c \ gnss.c \ ota_fullfota2.c \ + ota_url_policy.c \ #------------------------------------------------------------------------------- diff --git a/ql-application/threadx/interface/custom_app/inc/ota_url_policy.h b/ql-application/threadx/interface/custom_app/inc/ota_url_policy.h new file mode 100644 index 0000000..905148e --- /dev/null +++ b/ql-application/threadx/interface/custom_app/inc/ota_url_policy.h @@ -0,0 +1,6 @@ +#ifndef BANBAN_OTA_URL_POLICY_H +#define BANBAN_OTA_URL_POLICY_H + +int banban_ota_url_is_allowed(const char *url); + +#endif diff --git a/ql-application/threadx/interface/custom_app/mqtt.c b/ql-application/threadx/interface/custom_app/mqtt.c index 028f054..16cc6b3 100644 --- a/ql-application/threadx/interface/custom_app/mqtt.c +++ b/ql-application/threadx/interface/custom_app/mqtt.c @@ -22,6 +22,7 @@ #include "build_macro.h" #include "ql_log.h" #include "ota_fullfota2.h" +#include "ota_url_policy.h" @@ -46,7 +47,6 @@ #define OTA_STATE_FORMAT_VERSION 1U #define OTA_PROMPT_PLAY_TIMEOUT_MS 10000 #define OTA_PROMPT_POLL_MS 50 -#define OTA_ALLOWED_URL_PREFIX "http://101.35.224.118:8080/assets/firmware/" static ql_timer_t mqtt_event_task_timer = NULL; @@ -297,24 +297,6 @@ static int ota_compare_version(const char *left, const char *right, int *result) return 0; } -static int ota_url_is_allowed(const char *url) -{ - size_t prefix_len = strlen(OTA_ALLOWED_URL_PREFIX); - - if (url == NULL || url[0] == '\0') { - return 0; - } - - if (strncmp(url, OTA_ALLOWED_URL_PREFIX, prefix_len) != 0) { - return 0; - } - - if (url[prefix_len] == '\0' || strstr(url + prefix_len, "..") != NULL) { - return 0; - } - - return strstr(url + prefix_len, ".bin") != NULL; -} /* ==================== 解析函数 ==================== */ /** @@ -1318,7 +1300,7 @@ static void handle_ota_upgrade(command_t *cmd) return; } - if (!ota_url_is_allowed(cmd->params.ota.url)) { + if (!banban_ota_url_is_allowed(cmd->params.ota.url)) { RLOGE("OTA URL is not allowed: %s", cmd->params.ota.url); return; } diff --git a/ql-application/threadx/interface/custom_app/ota_url_policy.c b/ql-application/threadx/interface/custom_app/ota_url_policy.c new file mode 100644 index 0000000..5ac724c --- /dev/null +++ b/ql-application/threadx/interface/custom_app/ota_url_policy.c @@ -0,0 +1,189 @@ +#include +#include + +#include "ota_url_policy.h" + +#define BANBAN_OTA_LEGACY_URL_PREFIX \ + "http://101.35.224.118:8080/assets/firmware/" +#define BANBAN_OTA_BASE_DOMAIN "talkingq.com" + +static int banban_ascii_equal_nocase(const char *left, + const char *right, + size_t length) +{ + size_t i; + + for (i = 0; i < length; ++i) { + unsigned char a = (unsigned char)left[i]; + unsigned char b = (unsigned char)right[i]; + + if (a >= 'A' && a <= 'Z') { + a = (unsigned char)(a + ('a' - 'A')); + } + if (b >= 'A' && b <= 'Z') { + b = (unsigned char)(b + ('a' - 'A')); + } + if (a != b) { + return 0; + } + } + + return 1; +} + +static int banban_hostname_is_valid(const char *host, size_t host_length) +{ + size_t i; + int label_has_character = 0; + int previous_was_hyphen = 0; + + if (host_length == 0 || host_length > 253) { + return 0; + } + + for (i = 0; i < host_length; ++i) { + unsigned char character = (unsigned char)host[i]; + + if ((character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9')) { + label_has_character = 1; + previous_was_hyphen = 0; + continue; + } + + if (character == '-') { + if (!label_has_character) { + return 0; + } + previous_was_hyphen = 1; + continue; + } + + if (character == '.') { + if (!label_has_character || previous_was_hyphen) { + return 0; + } + label_has_character = 0; + previous_was_hyphen = 0; + continue; + } + + return 0; + } + + return label_has_character && !previous_was_hyphen; +} + +static int banban_host_is_talkingq(const char *host, size_t host_length) +{ + const size_t base_length = sizeof(BANBAN_OTA_BASE_DOMAIN) - 1; + + if (!banban_hostname_is_valid(host, host_length)) { + return 0; + } + + if (host_length == base_length) { + return banban_ascii_equal_nocase(host, + BANBAN_OTA_BASE_DOMAIN, + base_length); + } + + if (host_length <= base_length + 1 || + host[host_length - base_length - 1] != '.') { + return 0; + } + + return banban_ascii_equal_nocase(host + host_length - base_length, + BANBAN_OTA_BASE_DOMAIN, + base_length); +} + +static int banban_port_is_valid(const char *port, size_t port_length) +{ + size_t i; + unsigned long value = 0; + + if (port_length == 0 || port_length > 5) { + return 0; + } + + for (i = 0; i < port_length; ++i) { + if (port[i] < '0' || port[i] > '9') { + return 0; + } + value = value * 10 + (unsigned long)(port[i] - '0'); + } + + return value > 0 && value <= 65535; +} + +static int banban_path_is_fbf(const char *path) +{ + const char *path_end; + const char *query; + + if (path == NULL || path[0] != '/' || path[1] == '\0' || + strstr(path, "..") != NULL || strchr(path, '\\') != NULL || + strchr(path, '#') != NULL) { + return 0; + } + + query = strchr(path, '?'); + path_end = query != NULL ? query : path + strlen(path); + if ((size_t)(path_end - path) < 4) { + return 0; + } + + return banban_ascii_equal_nocase(path_end - 4, ".bin", 4); +} + +int banban_ota_url_is_allowed(const char *url) +{ + const char *authority; + const char *path; + const char *host_end; + const char *colon; + const size_t legacy_prefix_length = + sizeof(BANBAN_OTA_LEGACY_URL_PREFIX) - 1; + + if (url == NULL || url[0] == '\0') { + return 0; + } + + if (strncmp(url, BANBAN_OTA_LEGACY_URL_PREFIX, + legacy_prefix_length) == 0) { + return banban_path_is_fbf(url + legacy_prefix_length - 1); + } + + if (strncmp(url, "http://", 7) == 0) { + authority = url + 7; + } else if (strncmp(url, "https://", 8) == 0) { + authority = url + 8; + } else { + return 0; + } + + path = strchr(authority, '/'); + if (path == NULL || path == authority) { + return 0; + } + + if (memchr(authority, '@', (size_t)(path - authority)) != NULL) { + return 0; + } + + colon = memchr(authority, ':', (size_t)(path - authority)); + host_end = colon != NULL ? colon : path; + if (!banban_host_is_talkingq(authority, + (size_t)(host_end - authority))) { + return 0; + } + + if (colon != NULL && + !banban_port_is_valid(colon + 1, (size_t)(path - colon - 1))) { + return 0; + } + + return banban_path_is_fbf(path); +} diff --git a/tools/test_ota_url_policy.c b/tools/test_ota_url_policy.c new file mode 100644 index 0000000..3f8cff7 --- /dev/null +++ b/tools/test_ota_url_policy.c @@ -0,0 +1,53 @@ +#include + +#include "ota_url_policy.h" + +typedef struct { + const char *url; + int expected; +} url_case_t; + +int main(void) +{ + static const url_case_t cases[] = { + {"http://101.35.224.118:8080/assets/firmware/app.bin", 1}, + {"http://101.35.224.118:8080/assets/firmware/release/app.bin?token=x", 1}, + {"http://talkingq.com/app.bin", 1}, + {"https://talkingq.com/releases/app.bin", 1}, + {"https://ota.talkingq.com/releases/app.bin", 1}, + {"https://cdn.cn.talkingq.com:8443/a/B.BIN?token=x", 1}, + {"https://OTA.TALKINGQ.COM/app.bin", 1}, + {"https://eviltalkingq.com/app.bin", 0}, + {"https://talkingq.com.evil.example/app.bin", 0}, + {"https://talkingq.com@evil.example/app.bin", 0}, + {"https://evil.example@talkingq.com/app.bin", 0}, + {"https://a..talkingq.com/app.bin", 0}, + {"https://-ota.talkingq.com/app.bin", 0}, + {"https://ota-.talkingq.com/app.bin", 0}, + {"https://ota.talkingq.com:0/app.bin", 0}, + {"https://ota.talkingq.com:65536/app.bin", 0}, + {"https://ota.talkingq.com:abc/app.bin", 0}, + {"ftp://ota.talkingq.com/app.bin", 0}, + {"https://ota.talkingq.com/", 0}, + {"https://ota.talkingq.com/app.zip", 0}, + {"https://ota.talkingq.com/app.bin/extra", 0}, + {"https://ota.talkingq.com/a/../app.bin", 0}, + {"https://ota.talkingq.com/app.bin#fragment", 0}, + {"http://101.35.224.118:8080/other/app.bin", 0}, + {"http://192.168.1.1/app.bin", 0}, + }; + size_t i; + + for (i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + int actual = banban_ota_url_is_allowed(cases[i].url); + if (actual != cases[i].expected) { + fprintf(stderr, "case %lu failed: expected=%d actual=%d url=%s\n", + (unsigned long)i, cases[i].expected, actual, cases[i].url); + return 1; + } + } + + printf("OTA URL policy tests passed: %lu cases\n", + (unsigned long)(sizeof(cases) / sizeof(cases[0]))); + return 0; +} diff --git a/tools/verify_ota_ready_baseline.py b/tools/verify_ota_ready_baseline.py index e5bc966..a353177 100644 --- a/tools/verify_ota_ready_baseline.py +++ b/tools/verify_ota_ready_baseline.py @@ -23,6 +23,7 @@ BUILD_MACRO = ROOT / "ql-application/threadx/interface/custom_app/lib/hal/build_ APP_MAKEFILE = ROOT / "ql-application/threadx/interface/custom_app/Makefile" MQTT_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/mqtt.c" FULLOTA2_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/ota_fullfota2.c" +URL_POLICY_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/ota_url_policy.c" LEGACY_HTTP_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/http_aofei.c" FLASH_SIZE = 0x400000 @@ -146,11 +147,18 @@ def verify_fota_contract() -> None: makefile = APP_MAKEFILE.read_text(encoding="utf-8", errors="replace") if "ota_fullfota2.c" not in makefile: fail("minimal FULLOTA2 implementation is not part of the application build") + if "ota_url_policy.c" not in makefile: + fail("OTA URL policy is not part of the application build") mqtt = MQTT_SOURCE.read_text(encoding="utf-8", errors="replace") if "banban_fullfota2_start" not in mqtt: fail("MQTT OTA command does not start the local-staging FULLOTA2 path") if "ql_fullfota_app_start(cmd->params.ota.url)" in mqtt: fail("MQTT OTA command still invokes the legacy FULLOTA1 path") + if "banban_ota_url_is_allowed" not in mqtt: + fail("MQTT OTA command does not enforce the shared URL policy") + url_policy = URL_POLICY_SOURCE.read_text(encoding="utf-8", errors="replace") + if 'BANBAN_OTA_BASE_DOMAIN "talkingq.com"' not in url_policy: + fail("OTA URL policy no longer allows the talkingq.com domain family") legacy_http = LEGACY_HTTP_SOURCE.read_text(encoding="utf-8", errors="replace") if "return ql_fullfota_app_start(url)" in legacy_http: fail("legacy HTTP polling path can still invoke FULLOTA1")