From 3a18221eea90213dbacd8472847174a7c3ce1721 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 15 Feb 2026 22:00:10 -0800 Subject: [PATCH 1/2] perf: adaptive polling intervals for frontend (closes #43) - appDetail: poll every 1s during active deployments, 10s when idle - deploymentsPage: same adaptive polling for status checks - Skip fetching container/build logs when panes are not visible - Use setTimeout chains instead of setInterval for dynamic intervals --- static/js/app.js | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index cd567a9..4829867 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -185,11 +185,12 @@ document.addEventListener("alpine:init", () => { // Track whether user wants auto-scroll (per log pane) _containerAutoScroll: true, _buildAutoScroll: true, + _pollTimer: null, init() { this.deploying = Alpine.store("utils").isDeploying(this.appStatus); this.fetchAll(); - setInterval(() => this.fetchAll(), 1000); + this._schedulePoll(); // Set up scroll listeners after DOM is ready this.$nextTick(() => { @@ -198,6 +199,15 @@ document.addEventListener("alpine:init", () => { }); }, + _schedulePoll() { + if (this._pollTimer) clearTimeout(this._pollTimer); + const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000; + this._pollTimer = setTimeout(() => { + this.fetchAll(); + this._schedulePoll(); + }, interval); + }, + _initScrollTracking(el, flag) { if (!el) return; el.addEventListener('scroll', () => { @@ -207,18 +217,36 @@ document.addEventListener("alpine:init", () => { fetchAll() { this.fetchAppStatus(); - this.fetchContainerLogs(); - this.fetchBuildLogs(); + // Only fetch logs when the respective pane is visible + if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) { + this.fetchContainerLogs(); + } + if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) { + this.fetchBuildLogs(); + } this.fetchRecentDeployments(); }, + _isElementVisible(el) { + if (!el) return false; + // Check if element is in viewport (roughly) + const rect = el.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < window.innerHeight; + }, + async fetchAppStatus() { try { const res = await fetch(`/apps/${this.appId}/status`); const data = await res.json(); + const wasDeploying = this.deploying; this.appStatus = data.status; this.deploying = Alpine.store("utils").isDeploying(data.status); + // Re-schedule polling when deployment state changes + if (this.deploying !== wasDeploying) { + this._schedulePoll(); + } + if ( data.latestDeploymentID && data.latestDeploymentID !== this.currentDeploymentId @@ -429,7 +457,18 @@ document.addEventListener("alpine:init", () => { } this.fetchAppStatus(); - setInterval(() => this.fetchAppStatus(), 1000); + this._scheduleStatusPoll(); + }, + + _statusPollTimer: null, + + _scheduleStatusPoll() { + if (this._statusPollTimer) clearTimeout(this._statusPollTimer); + const interval = this.isDeploying ? 1000 : 10000; + this._statusPollTimer = setTimeout(() => { + this.fetchAppStatus(); + this._scheduleStatusPoll(); + }, interval); }, async fetchAppStatus() { @@ -464,6 +503,7 @@ document.addEventListener("alpine:init", () => { // Update deploying state based on latest deployment status if (deploying && !this.isDeploying) { this.isDeploying = true; + this._scheduleStatusPoll(); // Switch to fast polling } else if (!deploying && this.isDeploying) { // Deployment finished - reload to show final state this.isDeploying = false; From b1a6fd5fca2a99ff2a959731532ca65e154945d8 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 15 Feb 2026 22:01:54 -0800 Subject: [PATCH 2/2] fix: only trust proxy headers from RFC1918/loopback sources (closes #44) realIP() now parses RemoteAddr and checks if the source IP is in RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8), or IPv6 ULA/loopback ranges before trusting X-Real-IP or X-Forwarded-For headers. Public source IPs have headers ignored (fail closed). This prevents attackers from spoofing X-Forwarded-For to bypass the login rate limiter. --- internal/middleware/middleware.go | 51 ++++++++++++++++-- internal/middleware/realip_test.go | 83 +++++++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index bc650b8..69fa447 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -111,10 +111,53 @@ func ipFromHostPort(hostPort string) string { return host } -// realIP extracts the client's real IP address from the request, -// checking proxy headers first (trusted reverse proxy like Traefik), -// then falling back to RemoteAddr. +// trustedProxyNets are RFC1918 and loopback CIDRs whose proxy headers we trust. +// +//nolint:gochecknoglobals // package-level constant nets parsed once +var trustedProxyNets = func() []*net.IPNet { + cidrs := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "::1/128", + "fc00::/7", + } + + nets := make([]*net.IPNet, 0, len(cidrs)) + + for _, cidr := range cidrs { + _, n, _ := net.ParseCIDR(cidr) + nets = append(nets, n) + } + + return nets +}() + +// isTrustedProxy reports whether ip is in an RFC1918, loopback, or ULA range. +func isTrustedProxy(ip net.IP) bool { + for _, n := range trustedProxyNets { + if n.Contains(ip) { + return true + } + } + + return false +} + +// realIP extracts the client's real IP address from the request. +// Proxy headers (X-Real-IP, X-Forwarded-For) are only trusted when the +// direct connection originates from an RFC1918/loopback address. +// Otherwise, headers are ignored and RemoteAddr is used (fail closed). func realIP(r *http.Request) string { + addr := ipFromHostPort(r.RemoteAddr) + remoteIP := net.ParseIP(addr) + + // Only trust proxy headers from private/loopback sources. + if remoteIP == nil || !isTrustedProxy(remoteIP) { + return addr + } + // 1. X-Real-IP (set by Traefik/nginx) if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" { return ip @@ -130,7 +173,7 @@ func realIP(r *http.Request) string { } // 3. Fall back to RemoteAddr - return ipFromHostPort(r.RemoteAddr) + return addr } // CORS returns CORS middleware. diff --git a/internal/middleware/realip_test.go b/internal/middleware/realip_test.go index 8cd1632..f38aa48 100644 --- a/internal/middleware/realip_test.go +++ b/internal/middleware/realip_test.go @@ -2,6 +2,7 @@ package middleware //nolint:testpackage // tests unexported realIP function import ( "context" + "net" "net/http" "testing" ) @@ -16,54 +17,98 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test xff string want string }{ + // === Trusted proxy (RFC1918 / loopback) — headers ARE honoured === { - name: "X-Real-IP takes priority", + name: "trusted: X-Real-IP from 10.x", remoteAddr: "10.0.0.1:1234", xRealIP: "203.0.113.5", xff: "198.51.100.1, 10.0.0.1", want: "203.0.113.5", }, { - name: "X-Forwarded-For used when no X-Real-IP", + name: "trusted: XFF from 10.x when no X-Real-IP", remoteAddr: "10.0.0.1:1234", xff: "198.51.100.1, 10.0.0.1", want: "198.51.100.1", }, { - name: "X-Forwarded-For single IP", + name: "trusted: XFF single IP from 10.x", remoteAddr: "10.0.0.1:1234", xff: "203.0.113.10", want: "203.0.113.10", }, { - name: "falls back to RemoteAddr", + name: "trusted: falls back to RemoteAddr (192.168.x)", remoteAddr: "192.168.1.1:5678", want: "192.168.1.1", }, { - name: "RemoteAddr without port", + name: "trusted: RemoteAddr without port", remoteAddr: "192.168.1.1", want: "192.168.1.1", }, { - name: "X-Real-IP with whitespace", + name: "trusted: X-Real-IP with whitespace from 10.x", remoteAddr: "10.0.0.1:1234", xRealIP: " 203.0.113.5 ", want: "203.0.113.5", }, { - name: "X-Forwarded-For with whitespace", + name: "trusted: XFF with whitespace from 10.x", remoteAddr: "10.0.0.1:1234", xff: " 198.51.100.1 , 10.0.0.1", want: "198.51.100.1", }, { - name: "empty X-Real-IP falls through to XFF", + name: "trusted: empty X-Real-IP falls through to XFF from 10.x", remoteAddr: "10.0.0.1:1234", xRealIP: " ", xff: "198.51.100.1", want: "198.51.100.1", }, + { + name: "trusted: loopback honours X-Real-IP", + remoteAddr: "127.0.0.1:9999", + xRealIP: "93.184.216.34", + want: "93.184.216.34", + }, + { + name: "trusted: 172.16.x honours XFF", + remoteAddr: "172.16.0.1:4321", + xff: "8.8.8.8", + want: "8.8.8.8", + }, + + // === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr === + { + name: "untrusted: X-Real-IP ignored from public IP", + remoteAddr: "203.0.113.50:1234", + xRealIP: "10.0.0.1", + want: "203.0.113.50", + }, + { + name: "untrusted: XFF ignored from public IP", + remoteAddr: "198.51.100.99:5678", + xff: "10.0.0.1, 192.168.1.1", + want: "198.51.100.99", + }, + { + name: "untrusted: both headers ignored from public IP", + remoteAddr: "8.8.8.8:443", + xRealIP: "1.2.3.4", + xff: "5.6.7.8", + want: "8.8.8.8", + }, + { + name: "untrusted: no headers, public RemoteAddr", + remoteAddr: "93.184.216.34:8080", + want: "93.184.216.34", + }, + { + name: "untrusted: public RemoteAddr without port", + remoteAddr: "93.184.216.34", + want: "93.184.216.34", + }, } for _, tt := range tests { @@ -88,3 +133,25 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test }) } } + +func TestIsTrustedProxy(t *testing.T) { + t.Parallel() + + trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255", + "192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"} + untrusted := []string{"8.8.8.8", "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1"} + + for _, addr := range trusted { + ip := net.ParseIP(addr) + if !isTrustedProxy(ip) { + t.Errorf("expected %s to be trusted", addr) + } + } + + for _, addr := range untrusted { + ip := net.ParseIP(addr) + if isTrustedProxy(ip) { + t.Errorf("expected %s to be untrusted", addr) + } + } +}