Skip to content

Commit 75a3c88

Browse files
committed
Enhance PIN security and HTTP body handling
Added global caps for incoming HTTP body size and notefile processing per poll to prevent blocking. Improved PIN management in the client console with 90-day browser retention, session badges, and clearer UI hints. Enforced admin PIN validation for sensitive POST endpoints. Refined time sync logic and fixed static IP default in config loading.
1 parent 81e5696 commit 75a3c88

File tree

1 file changed

+129
-17
lines changed

1 file changed

+129
-17
lines changed

TankAlarm-112025-Server-BluesOpta/TankAlarm-112025-Server-BluesOpta.ino

Lines changed: 129 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@
182182
#define SERIAL_STALE_SECONDS 1800 // 30 minutes
183183
#endif
184184

185+
#ifndef MAX_HTTP_BODY_BYTES
186+
#define MAX_HTTP_BODY_BYTES 8192 // Global cap on incoming HTTP body size
187+
#endif
188+
189+
#ifndef MAX_NOTES_PER_FILE_PER_POLL
190+
#define MAX_NOTES_PER_FILE_PER_POLL 10 // Prevent long blocking notefile drains
191+
#endif
192+
185193
// Calibration learning system constants
186194
#ifndef CALIBRATION_LOG_PATH
187195
#define CALIBRATION_LOG_PATH "/calibration_log.txt"
@@ -380,6 +388,19 @@ static bool pinMatches(const char *pin) {
380388
return strncmp(pin, gConfig.configPin, sizeof(gConfig.configPin)) == 0;
381389
}
382390

391+
// Require that a valid admin PIN is configured and provided; respond with 403/400 on failure.
392+
static bool requireValidPin(EthernetClient &client, const char *pinValue) {
393+
if (gConfig.configPin[0] == '\0') {
394+
respondStatus(client, 403, F("Configure admin PIN before making changes"));
395+
return false;
396+
}
397+
if (!pinMatches(pinValue)) {
398+
respondStatus(client, 403, F("Invalid PIN"));
399+
return false;
400+
}
401+
return true;
402+
}
403+
383404
static const char CONFIG_GENERATOR_HTML[] PROGMEM = R"HTML(
384405
<!DOCTYPE html>
385406
<html lang="en">
@@ -3925,11 +3946,14 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
39253946
.modal {
39263947
position: fixed;
39273948
inset: 0;
3928-
background: rgba(15,23,42,0.65);
3949+
background: radial-gradient(circle at 20% 20%, rgba(99,102,241,0.12), transparent 30%),
3950+
radial-gradient(circle at 80% 30%, rgba(14,165,233,0.12), transparent 32%),
3951+
rgba(15,23,42,0.7);
39293952
display: flex;
39303953
align-items: center;
39313954
justify-content: center;
39323955
z-index: 999;
3956+
backdrop-filter: blur(4px);
39333957
}
39343958
.modal.hidden {
39353959
opacity: 0;
@@ -3938,14 +3962,36 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
39383962
.modal-card {
39393963
background: var(--surface);
39403964
border-radius: 16px;
3941-
padding: 24px;
3965+
padding: 28px 26px 24px;
39423966
width: min(420px, 90%);
39433967
border: 1px solid var(--card-border);
3944-
box-shadow: 0 20px 40px var(--card-shadow);
3968+
box-shadow: 0 24px 50px rgba(15,23,42,0.35);
3969+
position: relative;
39453970
}
39463971
.modal-card h2 {
39473972
margin-top: 0;
39483973
}
3974+
.modal-card .field + .field,
3975+
.modal-card .field + .actions {
3976+
margin-top: 12px;
3977+
}
3978+
.pin-hint {
3979+
display: block;
3980+
margin-top: 6px;
3981+
color: var(--muted);
3982+
font-size: 0.9rem;
3983+
}
3984+
.modal-badge {
3985+
position: absolute;
3986+
top: 14px;
3987+
right: 16px;
3988+
padding: 6px 10px;
3989+
border-radius: 999px;
3990+
font-size: 0.75rem;
3991+
background: var(--chip);
3992+
color: var(--muted);
3993+
border: 1px solid var(--card-border);
3994+
}
39493995
.hidden {
39503996
display: none !important;
39513997
}
@@ -4073,6 +4119,7 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
40734119
<div id="toast"></div>
40744120
<div id="pinModal" class="modal hidden">
40754121
<div class="modal-card">
4122+
<div class="modal-badge" id="pinSessionBadge">Session</div>
40764123
<h2 id="pinModalTitle">Set Admin PIN</h2>
40774124
<p id="pinModalDescription">Enter a 4-digit PIN to unlock configuration changes.</p>
40784125
<form id="pinForm">
@@ -4082,7 +4129,8 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
40824129
</label>
40834130
<label class="field" id="pinPrimaryGroup">
40844131
<span id="pinPrimaryLabel">PIN</span>
4085-
<input type="password" id="pinInput" inputmode="numeric" pattern="\d*" maxlength="4" autocomplete="off" required>
4132+
<input type="password" id="pinInput" inputmode="numeric" pattern="\d*" maxlength="4" autocomplete="off" required placeholder="4 digits" aria-describedby="pinHint" title="Enter exactly four digits (0-9)">
4133+
<small class="pin-hint" id="pinHint">Use exactly 4 digits (0-9). The PIN is kept locally in this browser for 90 days.</small>
40864134
</label>
40874135
<label class="field hidden" id="pinConfirmGroup">
40884136
<span>Confirm PIN</span>
@@ -4113,13 +4161,40 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
41134161
});
41144162
41154163
const PIN_STORAGE_KEY = 'tankalarmPin';
4164+
const PIN_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90-day browser retention
4165+
function loadStoredPin() {
4166+
try {
4167+
const raw = localStorage.getItem(PIN_STORAGE_KEY);
4168+
if (!raw) return null;
4169+
const parsed = JSON.parse(raw);
4170+
if (!parsed || !parsed.pin || !parsed.expiresAt) {
4171+
localStorage.removeItem(PIN_STORAGE_KEY);
4172+
return null;
4173+
}
4174+
if (Date.now() > parsed.expiresAt) {
4175+
localStorage.removeItem(PIN_STORAGE_KEY);
4176+
return null;
4177+
}
4178+
return parsed.pin;
4179+
} catch (err) {
4180+
localStorage.removeItem(PIN_STORAGE_KEY);
4181+
return null;
4182+
}
4183+
}
4184+
function storePin(pin) {
4185+
const payload = { pin, expiresAt: Date.now() + PIN_SESSION_TTL_MS };
4186+
localStorage.setItem(PIN_STORAGE_KEY, JSON.stringify(payload));
4187+
}
4188+
function clearStoredPin() {
4189+
localStorage.removeItem(PIN_STORAGE_KEY);
4190+
}
41164191
const state = {
41174192
data: null,
41184193
selected: null
41194194
};
41204195
41214196
const pinState = {
4122-
value: sessionStorage.getItem(PIN_STORAGE_KEY) || null,
4197+
value: loadStoredPin() || null,
41234198
configured: false,
41244199
mode: 'unlock'
41254200
};
@@ -4187,7 +4262,7 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
41874262
41884263
function invalidatePin() {
41894264
pinState.value = null;
4190-
sessionStorage.removeItem(PIN_STORAGE_KEY);
4265+
clearStoredPin();
41914266
setFormDisabled(true);
41924267
}
41934268
@@ -4210,17 +4285,20 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
42104285
pinEls.description.textContent = 'Choose a 4-digit PIN to secure configuration changes.';
42114286
pinEls.confirmGroup.classList.remove('hidden');
42124287
pinEls.cancel.classList.add('hidden');
4288+
document.getElementById('pinSessionBadge').textContent = 'Required';
42134289
} else if (mode === 'change') {
42144290
pinEls.title.textContent = 'Change Admin PIN';
42154291
pinEls.description.textContent = 'Enter your current PIN and the new PIN you would like to use.';
42164292
pinEls.currentGroup.classList.remove('hidden');
42174293
pinEls.confirmGroup.classList.remove('hidden');
42184294
pinEls.primaryLabel.textContent = 'New PIN';
42194295
pinEls.cancel.classList.remove('hidden');
4296+
document.getElementById('pinSessionBadge').textContent = 'Secured';
42204297
} else {
42214298
pinEls.title.textContent = 'Enter Admin PIN';
4222-
pinEls.description.textContent = 'Enter the admin PIN to unlock configuration controls.';
4299+
pinEls.description.textContent = 'Enter the admin PIN to unlock configuration controls. We keep it in this browser for 90 days.';
42234300
pinEls.cancel.classList.remove('hidden');
4301+
document.getElementById('pinSessionBadge').textContent = 'Locked';
42244302
}
42254303
pinEls.modal.classList.remove('hidden');
42264304
setFormDisabled(true);
@@ -4290,7 +4368,7 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
42904368
42914369
const result = await requestPin(payload);
42924370
pinState.value = pinToStore;
4293-
sessionStorage.setItem(PIN_STORAGE_KEY, pinToStore);
4371+
storePin(pinToStore);
42944372
hidePinModal();
42954373
setFormDisabled(false);
42964374
showToast((result && result.message) || 'PIN updated');
@@ -4323,6 +4401,7 @@ static const char CLIENT_CONSOLE_HTML[] PROGMEM = R"HTML(
43234401
showPinModal('unlock');
43244402
}
43254403
} else {
4404+
storePin(pinState.value); // Refresh TTL on activity
43264405
setFormDisabled(false);
43274406
hidePinModal();
43284407
}
@@ -4781,7 +4860,7 @@ static void scheduleNextDailyEmail();
47814860
static void scheduleNextViewerSummary();
47824861
static void initializeEthernet();
47834862
static void handleWebRequests();
4784-
static bool readHttpRequest(EthernetClient &client, String &method, String &path, String &body, size_t &contentLength);
4863+
static bool readHttpRequest(EthernetClient &client, String &method, String &path, String &body, size_t &contentLength, bool &bodyTooLarge);
47854864
static void respondHtml(EthernetClient &client, const String &body);
47864865
static void respondJson(EthernetClient &client, const String &body, int status = 200);
47874866
static void respondStatus(EthernetClient &client, int status, const String &message);
@@ -4846,19 +4925,26 @@ static bool checkSmsRateLimit(TankRecord *rec);
48464925
static void publishViewerSummary();
48474926
static double computeNextAlignedEpoch(double epoch, uint8_t baseHour, uint32_t intervalSeconds);
48484927
static String getQueryParam(const String &query, const char *key);
4928+
static bool requireValidPin(EthernetClient &client, const char *pinValue);
48494929

48504930
static void handleRefreshPost(EthernetClient &client, const String &body) {
48514931
char clientUid[64] = {0};
4932+
const char *pinValue = nullptr;
48524933
if (body.length() > 0) {
4853-
DynamicJsonDocument doc(128);
4934+
DynamicJsonDocument doc(192);
48544935
if (deserializeJson(doc, body) == DeserializationError::Ok) {
48554936
const char *uid = doc["client"] | "";
48564937
if (uid && *uid) {
48574938
strlcpy(clientUid, uid, sizeof(clientUid));
48584939
}
4940+
pinValue = doc["pin"].as<const char *>();
48594941
}
48604942
}
48614943

4944+
if (!requireValidPin(client, pinValue)) {
4945+
return;
4946+
}
4947+
48624948
if (clientUid[0]) {
48634949
Serial.print(F("Manual refresh requested for client " ));
48644950
Serial.println(clientUid);
@@ -5537,7 +5623,7 @@ static bool loadConfig(ServerConfig &cfg) {
55375623
} else {
55385624
cfg.webRefreshSeconds = 21600;
55395625
}
5540-
cfg.useStaticIp = doc["useStaticIp"].is<bool>() ? doc["useStaticIp"].as<bool>() : true;
5626+
cfg.useStaticIp = doc["useStaticIp"].is<bool>() ? doc["useStaticIp"].as<bool>() : false;
55415627
cfg.smsOnHigh = doc["smsOnHigh"].is<bool>() ? doc["smsOnHigh"].as<bool>() : true;
55425628
cfg.smsOnLow = doc["smsOnLow"].is<bool>() ? doc["smsOnLow"].as<bool>() : true;
55435629
cfg.smsOnClear = doc["smsOnClear"].is<bool>() ? doc["smsOnClear"].as<bool>() : false;
@@ -5704,7 +5790,7 @@ static void initializeNotecard() {
57045790
}
57055791

57065792
static void ensureTimeSync() {
5707-
if (gLastSyncedEpoch <= 0.0 || millis() - gLastSyncMillis > 6UL * 60UL * 60UL * 1000UL) {
5793+
if (gLastSyncedEpoch <= 0.0 || (uint32_t)(millis() - gLastSyncMillis) > 6UL * 60UL * 60UL * 1000UL) {
57085794
J *req = notecard.newRequest("card.time");
57095795
if (!req) {
57105796
return;
@@ -5736,7 +5822,7 @@ static double currentEpoch() {
57365822
if (gLastSyncedEpoch <= 0.0) {
57375823
return 0.0;
57385824
}
5739-
unsigned long delta = millis() - gLastSyncMillis;
5825+
uint32_t delta = (uint32_t)(millis() - gLastSyncMillis); // Handles millis() rollover
57405826
return gLastSyncedEpoch + (double)delta / 1000.0;
57415827
}
57425828

@@ -5826,13 +5912,20 @@ static void handleWebRequests() {
58265912
String path;
58275913
String body;
58285914
size_t contentLength = 0;
5915+
bool bodyTooLarge = false;
58295916

5830-
if (!readHttpRequest(client, method, path, body, contentLength)) {
5917+
if (!readHttpRequest(client, method, path, body, contentLength, bodyTooLarge)) {
58315918
respondStatus(client, 400, F("Bad Request"));
58325919
client.stop();
58335920
return;
58345921
}
58355922

5923+
if (bodyTooLarge) {
5924+
respondStatus(client, 413, F("Payload Too Large"));
5925+
client.stop();
5926+
return;
5927+
}
5928+
58365929
if (method == "GET" && path == "/") {
58375930
sendDashboard(client);
58385931
} else if (method == "GET" && path == "/client-console") {
@@ -5907,11 +6000,12 @@ static void handleWebRequests() {
59076000
client.stop();
59086001
}
59096002

5910-
static bool readHttpRequest(EthernetClient &client, String &method, String &path, String &body, size_t &contentLength) {
6003+
static bool readHttpRequest(EthernetClient &client, String &method, String &path, String &body, size_t &contentLength, bool &bodyTooLarge) {
59116004
method = "";
59126005
path = "";
59136006
contentLength = 0;
59146007
body = "";
6008+
bodyTooLarge = false;
59156009

59166010
String line;
59176011
bool firstLine = true;
@@ -5952,6 +6046,10 @@ static bool readHttpRequest(EthernetClient &client, String &method, String &path
59526046
headerValue.trim();
59536047
if (headerKey.equalsIgnoreCase("Content-Length")) {
59546048
contentLength = headerValue.toInt();
6049+
if (contentLength > MAX_HTTP_BODY_BYTES) {
6050+
bodyTooLarge = true;
6051+
contentLength = MAX_HTTP_BODY_BYTES;
6052+
}
59556053
}
59566054
}
59576055
}
@@ -5972,6 +6070,10 @@ static bool readHttpRequest(EthernetClient &client, String &method, String &path
59726070
body += c;
59736071
readBytes++;
59746072
}
6073+
if (readBytes >= MAX_HTTP_BODY_BYTES) {
6074+
bodyTooLarge = true;
6075+
break;
6076+
}
59756077
}
59766078
}
59776079

@@ -6393,12 +6495,17 @@ static void handlePinPost(EthernetClient &client, const String &body) {
63936495
}
63946496

63956497
static void handleRelayPost(EthernetClient &client, const String &body) {
6396-
DynamicJsonDocument doc(512);
6498+
DynamicJsonDocument doc(640);
63976499
if (deserializeJson(doc, body)) {
63986500
respondStatus(client, 400, F("Invalid JSON"));
63996501
return;
64006502
}
64016503

6504+
const char *pinValue = doc["pin"].as<const char *>();
6505+
if (!requireValidPin(client, pinValue)) {
6506+
return;
6507+
}
6508+
64026509
const char *clientUid = doc["clientUid"].as<const char *>();
64036510
if (!clientUid || strlen(clientUid) == 0) {
64046511
respondStatus(client, 400, F("Missing clientUid"));
@@ -6510,7 +6617,8 @@ static void pollNotecard() {
65106617
}
65116618

65126619
static void processNotefile(const char *fileName, void (*handler)(JsonDocument &, double)) {
6513-
while (true) {
6620+
uint8_t processed = 0;
6621+
while (processed < MAX_NOTES_PER_FILE_PER_POLL) {
65146622
J *req = notecard.newRequest("note.get");
65156623
if (!req) {
65166624
return;
@@ -6542,6 +6650,10 @@ static void processNotefile(const char *fileName, void (*handler)(JsonDocument &
65426650
}
65436651

65446652
notecard.deleteResponse(rsp);
6653+
processed++;
6654+
6655+
// Yield to keep the loop responsive and allow watchdog kicks elsewhere
6656+
delay(1);
65456657
}
65466658
}
65476659

0 commit comments

Comments
 (0)