-
-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Securing OTA update #4700
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Securing OTA update #4700
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ static const char s_redirecting[] PROGMEM = "Redirecting..."; | |
| static const char s_content_enc[] PROGMEM = "Content-Encoding"; | ||
| static const char s_unlock_ota [] PROGMEM = "Please unlock OTA in security settings!"; | ||
| static const char s_unlock_cfg [] PROGMEM = "Please unlock settings using PIN code!"; | ||
| static const char s_rebooting [] PROGMEM = "Rebooting now..."; | ||
| static const char s_notimplemented[] PROGMEM = "Not implemented"; | ||
| static const char s_accessdenied[] PROGMEM = "Access Denied"; | ||
| static const char _common_js[] PROGMEM = "/common.js"; | ||
|
|
@@ -31,6 +32,22 @@ static bool isIp(const String &str) { | |
| return true; | ||
| } | ||
|
|
||
| static bool inSubnet(const IPAddress &ip, const IPAddress &subnet, const IPAddress &mask) { | ||
| return ((ip & mask) == (subnet & mask)); | ||
| } | ||
|
|
||
| static bool inSameSubnet(const IPAddress &client) { | ||
| return inSubnet(client, Network.localIP(), Network.subnetMask()); | ||
| } | ||
|
|
||
| static bool inLocalSubnet(const IPAddress &client) { | ||
| return inSubnet(client, IPAddress(10,0,0,0), IPAddress(255,0,0,0)) // 10.x.x.x | ||
| || inSubnet(client, IPAddress(192,168,0,0), IPAddress(255,255,0,0)) // 192.168.x.x | ||
| || inSubnet(client, IPAddress(172,16,0,0), IPAddress(255,240,0,0)) // 172.16.x.x | ||
| || (inSubnet(client, IPAddress(4,3,2,0), IPAddress(255,255,255,0)) && apActive) // WLED AP | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to test for this explicitly? Shouldn't this also pass
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Arg, I forget that the AP might as well be a separate ethernet device as the client mode. Only real note here is that 'apActive' should probably be tested first; it'll save a couple of cycles in the average case, and be clearer that this line is different than the above. |
||
| || inSameSubnet(client); // same subnet as WLED device | ||
| } | ||
|
|
||
| /* | ||
| * Integrated HTTP web server page declarations | ||
| */ | ||
|
|
@@ -130,7 +147,7 @@ static String msgProcessor(const String& var) | |
| if (optt < 60) //redirect to settings after optionType seconds | ||
| { | ||
| messageBody += F("<script>setTimeout(RS,"); | ||
| messageBody +=String(optt*1000); | ||
| messageBody += String(optt*1000); | ||
| messageBody += F(")</script>"); | ||
| } else if (optt < 120) //redirect back after optionType-60 seconds, unused | ||
| { | ||
|
|
@@ -270,7 +287,7 @@ void initServer() | |
| }); | ||
|
|
||
| server.on(F("/reset"), HTTP_GET, [](AsyncWebServerRequest *request){ | ||
| serveMessage(request, 200,F("Rebooting now..."),F("Please wait ~10 seconds..."),129); | ||
| serveMessage(request, 200, FPSTR(s_rebooting), F("Please wait ~10 seconds."), 131); | ||
| doReboot = true; | ||
| }); | ||
|
|
||
|
|
@@ -385,10 +402,16 @@ void initServer() | |
| if (Update.hasError()) { | ||
| serveMessage(request, 500, F("Update failed!"), F("Please check your file and retry!"), 254); | ||
| } else { | ||
| serveMessage(request, 200, F("Update successful!"), F("Rebooting..."), 131); | ||
| serveMessage(request, 200, F("Update successful!"), FPSTR(s_rebooting), 131); | ||
| doReboot = true; | ||
| } | ||
| },[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool isFinal){ | ||
| IPAddress client = request->client()->remoteIP(); | ||
| if (((otaSameSubnet && !inSameSubnet(client)) && !strlen(settingsPIN)) || (!otaSameSubnet && !inLocalSubnet(client))) { | ||
| DEBUG_PRINTLN(F("Attempted OTA update from different/non-local subnet!")); | ||
| request->send(401, FPSTR(CONTENT_TYPE_PLAIN), FPSTR(s_accessdenied)); | ||
| return; | ||
| } | ||
|
Comment on lines
+409
to
+414
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security logic needs clarification. The OTA update access control condition is complex and might have unintended behavior: This logic means:
This could allow OTA updates from different subnets if a PIN is set, even when Consider simplifying the logic: -if (((otaSameSubnet && !inSameSubnet(client)) && !strlen(settingsPIN)) || (!otaSameSubnet && !inLocalSubnet(client))) {
+if ((otaSameSubnet && !inSameSubnet(client)) || (!otaSameSubnet && !inLocalSubnet(client))) {🤖 Prompt for AI Agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was the intended behaviour. I admit it may not be the best.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @blazoncek Thank you for clarifying that this was the intended behavior. I understand the design choice - allowing PIN-protected OTA updates from different subnets when The trade-off is between convenience (remote OTA with PIN) and security (strict subnet enforcement). If you ever decide to revisit this, you might consider:
But I appreciate you confirming the intentional design! ✏️ Learnings added
|
||
| if (!correctPIN || otaLock) return; | ||
| if(!index){ | ||
| DEBUG_PRINTLN(F("OTA Update Start")); | ||
|
|
@@ -573,6 +596,11 @@ void serveSettings(AsyncWebServerRequest* request, bool post) { | |
| } | ||
|
|
||
| if (post) { //settings/set POST request, saving | ||
| IPAddress client = request->client()->remoteIP(); | ||
| if (!inLocalSubnet(client)) { // includes same subnet check | ||
| serveMessage(request, 401, FPSTR(s_accessdenied), FPSTR(s_redirecting), 123); | ||
| return; | ||
| } | ||
| if (subPage != SUBPAGE_WIFI || !(wifiLock && otaLock)) handleSettingsSet(request, subPage); | ||
|
|
||
| char s[32]; | ||
|
|
@@ -624,7 +652,17 @@ void serveSettings(AsyncWebServerRequest* request, bool post) { | |
| case SUBPAGE_DMX : content = PAGE_settings_dmx; len = PAGE_settings_dmx_length; break; | ||
| #endif | ||
| case SUBPAGE_UM : content = PAGE_settings_um; len = PAGE_settings_um_length; break; | ||
| case SUBPAGE_UPDATE : content = PAGE_update; len = PAGE_update_length; break; | ||
| case SUBPAGE_UPDATE : content = PAGE_update; len = PAGE_update_length; | ||
| if (request->hasArg(F("revert")) && inLocalSubnet(request->client()->remoteIP()) && Update.canRollBack()) { | ||
| doReboot = Update.rollBack(); | ||
| if (doReboot) { | ||
| serveMessage(request, 200, F("Reverted to previous version!"), FPSTR(s_rebooting), 133); | ||
| } else { | ||
| serveMessage(request, 500, F("Rollback failed!"), F("Please reboot and retry."), 254); | ||
| } | ||
| return; | ||
| } | ||
| break; | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| #ifndef WLED_DISABLE_2D | ||
| case SUBPAGE_2D : content = PAGE_settings_2D; len = PAGE_settings_2D_length; break; | ||
| #endif | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Those darn old ESP32 platforms. :( In a modern system (ESP8266, ESP32 v4 or newer) we should be using
client.isLocal()instead.