From 7996395d31b72787d9897e2f192edb7f348d948b Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Mon, 24 Aug 2026 11:12:20 -0700 Subject: [PATCH 1/2] Add Flask password-sync utility FlaskPasswordSync calls Flask's internal sync endpoint (POST /api/internal/sync-password) with a shared secret (INTERNAL_SYNC_KEY), so a password reset completed on Spring also lands on the Flask account for the same uid. Not wired up to any caller yet -- that's the OAuth-verified reset flow, next PR in the stack. Best-effort: a sync failure is logged, not fatal to whatever already-successful operation triggered it. Co-Authored-By: Claude Sonnet 5 --- .../spring/mvc/person/FlaskPasswordSync.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java diff --git a/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java b/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java new file mode 100644 index 00000000..ac393ba0 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java @@ -0,0 +1,84 @@ +package com.open.spring.mvc.person; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.github.cdimascio.dotenv.Dotenv; + +// Server-to-server call into Flask's /api/internal/sync-password, so a password +// reset completed here (OAuth + student ID verified) also lands on the Flask +// account for the same uid. Gated by a shared secret (INTERNAL_SYNC_KEY) that +// must match Flask's own config -- see GoogleIdTokenVerifier for the same +// env-then-dotenv resolution pattern used here. +public class FlaskPasswordSync { + private static final Logger logger = LoggerFactory.getLogger(FlaskPasswordSync.class); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private static String resolve(String envKey, String fallback) { + String value = System.getenv(envKey); + if (value != null && !value.isBlank()) { + return value; + } + try { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + value = dotenv.get(envKey); + if (value != null && !value.isBlank()) { + return value; + } + } catch (Exception e) { + // fall through to default + } + return fallback; + } + + // Best-effort: the Spring-side reset has already succeeded by the time this is + // called, so a Flask sync failure is logged and swallowed rather than failing + // the whole request -- the user's new password is already live on Spring, + // which is the backend this feature actually verified identity against. + public static boolean syncPassword(String uid, String newPassword) { + String syncKey = resolve("INTERNAL_SYNC_KEY", null); + String flaskUri = resolve("FLASK_URI", "http://localhost:8587"); + + if (syncKey == null) { + logger.warn("AUDIT flask_password_sync_skipped uid={} reason=no_sync_key_configured", uid); + return false; + } + + try { + JSONObject payload = new JSONObject(); + payload.put("uid", uid); + payload.put("password", newPassword); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(flaskUri + "/api/internal/sync-password")) + .header("Content-Type", "application/json") + .header("X-Internal-Sync-Key", syncKey) + .timeout(Duration.ofSeconds(10)) + .POST(HttpRequest.BodyPublishers.ofString(payload.toString(), StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + logger.info("AUDIT flask_password_sync_succeeded uid={}", uid); + return true; + } + + logger.warn("AUDIT flask_password_sync_failed uid={} status={}", uid, response.statusCode()); + return false; + } catch (Exception e) { + logger.warn("AUDIT flask_password_sync_failed uid={} reason=exception msg={}", uid, e.getMessage()); + return false; + } + } +} From 9c426028b45a8a21d6c0615cfe284a9b6a9d9981 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Mon, 24 Aug 2026 11:17:41 -0700 Subject: [PATCH 2/2] Refuse to sync a plaintext password to Flask over a non-loopback, non-TLS URI FlaskPasswordSync's request body carries the new password in plaintext, protected only by the shared-secret header, not encryption. The deployment topology (both nginx configs front the same public IP and proxy to localhost, and both READMEs describe deploying through the same cockpit) says this is same-host loopback traffic today, but no tracked config in either repo actually pins FLASK_URI to that. Adds a transport check before the call: allow loopback (localhost or 127.0.0.1, any scheme) or any https URI, otherwise skip the sync and log why. The host is parsed with java.net.URI rather than string-prefix matching, so a lookalike like http://localhost.attacker.com can't slip past. Co-Authored-By: Claude Sonnet 5 --- .../spring/mvc/person/FlaskPasswordSync.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java b/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java index ac393ba0..311ede3b 100644 --- a/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java +++ b/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java @@ -41,6 +41,23 @@ private static String resolve(String envKey, String fallback) { return fallback; } + // The request body carries the new password in plaintext, so this call is only safe if + // it's either loopback (same-box, never hits a real network) or TLS-wrapped. Parses the + // actual host rather than string-prefix-matching flaskUri, since a prefix check like + // startsWith("http://localhost") would wrongly pass a lookalike host such as + // "http://localhost.attacker.com". + private static boolean isSecureTransport(String flaskUri) { + try { + URI parsed = URI.create(flaskUri); + String host = parsed.getHost(); + boolean isLoopback = "localhost".equals(host) || "127.0.0.1".equals(host); + boolean isHttps = "https".equals(parsed.getScheme()); + return isLoopback || isHttps; + } catch (Exception e) { + return false; + } + } + // Best-effort: the Spring-side reset has already succeeded by the time this is // called, so a Flask sync failure is logged and swallowed rather than failing // the whole request -- the user's new password is already live on Spring, @@ -54,6 +71,11 @@ public static boolean syncPassword(String uid, String newPassword) { return false; } + if (!isSecureTransport(flaskUri)) { + logger.warn("AUDIT flask_password_sync_skipped uid={} reason=insecure_flask_uri uri={}", uid, flaskUri); + return false; + } + try { JSONObject payload = new JSONObject(); payload.put("uid", uid);