Skip to content

Commit dca96c0

Browse files
author
DatanoiseTV
committed
fix(security): don't ban IPs for repeated 404s on the same path
The listener 404 path was calling recordScanAttempt() for every unknown mount request, counting raw hits. A user opening the web player while their stream was offline (or a browser doing favicon/manifest prefetch before any mount was registered) could easily rack up 10 hits from the same IP in a few seconds and get a 15-minute TCP-level ban. - Skip recordScanAttempt entirely for mounts that match any configured stream (Mounts, AdvancedMounts, VisibleMounts, FallbackMounts, or a user's per-mount password) — those paths are legitimate even when no source is currently connected. - Skip it for obvious browser prefetch paths: /.well-known/*, *.ico, *.png, *.jpg, *.css, *.js, *.xml, *.json, *.webmanifest, fonts … - Gate the lockout on *distinct* path count (>=25) rather than raw hit count. Real scanners touch many paths; a flapping listener hits one. - Update the test to reflect the new intent.
1 parent 81c799c commit dca96c0

3 files changed

Lines changed: 78 additions & 18 deletions

File tree

server/auth.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,19 @@ func (s *Server) recordScanAttempt(ip, path string) {
140140
attempt.Paths[path] = true
141141
attempt.Count++
142142

143-
if attempt.Count >= 10 {
143+
// Require a large spread of distinct 404 paths before locking an IP
144+
// out: real vuln scanners probe dozens of unrelated paths, whereas a
145+
// legitimate listener reconnecting to an offline mount hits the same
146+
// path repeatedly. Counting distinct paths rather than raw hits stops
147+
// well-behaved clients from tripping the lockout.
148+
if len(attempt.Paths) >= 25 {
144149
attempt.LockoutBy = time.Now().Add(15 * time.Minute)
145-
logger.L.Warnf("IP %s locked out for 15 minutes due to 10 scanning attempts (404s)", ip)
150+
logger.L.Warnf("IP %s locked out for 15 minutes after %d distinct 404 paths (likely scanner)", ip, len(attempt.Paths))
146151
s.dispatchWebhook("security_lockout", map[string]interface{}{
147152
"ip": ip,
148153
"reason": "connection_scanning",
149154
"until": attempt.LockoutBy.Format(time.RFC3339),
150-
"details": "10 connection scanning attempts (404s)",
155+
"details": fmt.Sprintf("%d distinct 404 paths", len(attempt.Paths)),
151156
})
152157
}
153158
}

server/auth_test.go

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package server
22

33
import (
4+
"fmt"
45
"testing"
56

67
"github.com/DatanoiseTV/tinyice/config"
@@ -45,29 +46,35 @@ func TestIPWhitelistAndBanning(t *testing.T) {
4546
t.Errorf("::1 should be always whitelisted")
4647
}
4748

48-
// Verify scan attempt lockout behavior
49+
// Verify scan attempt lockout behavior: a legit listener hammering a
50+
// single offline mount path should *never* trigger the ban — only a
51+
// scanner touching many distinct paths should.
4952
ip := "8.8.8.8"
50-
path := "/wp-admin"
5153

52-
// Record 9 attempts on SAME path
53-
for i := 0; i < 9; i++ {
54-
s.recordScanAttempt(ip, path)
54+
for i := 0; i < 200; i++ {
55+
s.recordScanAttempt(ip, "/live")
5556
}
56-
5757
if s.isBanned(ip) {
58-
t.Errorf("IP %s should not be banned yet after 9 attempts", ip)
58+
t.Errorf("IP %s should NOT be banned for repeated 404s on the same path", ip)
5959
}
6060

61-
// 10th attempt should ban it
62-
s.recordScanAttempt(ip, path)
63-
if !s.isBanned(ip) {
64-
t.Errorf("IP %s should be banned after 10 attempts on SAME path (due to fix)", ip)
61+
// 25 distinct paths is the scanner threshold.
62+
scanner := "8.8.4.4"
63+
for i := 0; i < 24; i++ {
64+
s.recordScanAttempt(scanner, fmt.Sprintf("/probe-%d", i))
65+
}
66+
if s.isBanned(scanner) {
67+
t.Errorf("IP %s should not be banned yet after 24 distinct paths", scanner)
68+
}
69+
s.recordScanAttempt(scanner, "/probe-24")
70+
if !s.isBanned(scanner) {
71+
t.Errorf("IP %s should be banned after 25 distinct paths", scanner)
6572
}
6673

6774
// Verify whitelisted IP is NOT banned even after many attempts
6875
wip := "192.168.1.1"
69-
for i := 0; i < 20; i++ {
70-
s.recordScanAttempt(wip, path)
76+
for i := 0; i < 50; i++ {
77+
s.recordScanAttempt(wip, fmt.Sprintf("/wp-%d", i))
7178
}
7279
if s.isBanned(wip) {
7380
t.Errorf("Whitelisted IP %s should NOT be banned even after many attempts", wip)

server/handlers_stream.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,47 @@ func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
212212
s.Relay.RemoveStream(mount)
213213
}
214214

215+
// looksLikeUnknownMount reports whether a 404 for this path should count
216+
// toward the vuln-scanner lockout. Anything that is configured as a stream
217+
// (even if no source is currently connected) is a legitimate listener URL;
218+
// browser prefetch paths with static file extensions are also ignored.
219+
func (s *Server) looksLikeUnknownMount(mount string) bool {
220+
// Known configured mount — always legitimate.
221+
if _, ok := s.Config.Mounts[mount]; ok {
222+
return false
223+
}
224+
if _, ok := s.Config.AdvancedMounts[mount]; ok {
225+
return false
226+
}
227+
if _, ok := s.Config.VisibleMounts[mount]; ok {
228+
return false
229+
}
230+
if _, ok := s.Config.FallbackMounts[mount]; ok {
231+
return false
232+
}
233+
for _, u := range s.Config.Users {
234+
if _, ok := u.Mounts[mount]; ok {
235+
return false
236+
}
237+
}
238+
// Browser prefetch paths — not a stream attempt.
239+
lower := strings.ToLower(mount)
240+
if strings.HasPrefix(lower, "/.well-known/") {
241+
return false
242+
}
243+
staticSuffixes := []string{
244+
".ico", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp",
245+
".css", ".js", ".map", ".json", ".xml", ".txt", ".webmanifest",
246+
".woff", ".woff2", ".ttf",
247+
}
248+
for _, ext := range staticSuffixes {
249+
if strings.HasSuffix(lower, ext) {
250+
return false
251+
}
252+
}
253+
return true
254+
}
255+
215256
// isOggContentType returns true if the given HTTP Content-Type indicates an
216257
// Ogg-container stream (Vorbis, Opus, FLAC-in-Ogg, etc.).
217258
func isOggContentType(ct string) bool {
@@ -329,8 +370,15 @@ func (s *Server) handleListener(w http.ResponseWriter, r *http.Request) {
329370
time.Sleep(1 * time.Second)
330371
continue
331372
}
332-
host, _, _ := net.SplitHostPort(r.RemoteAddr)
333-
s.recordScanAttempt(host, mount)
373+
// Only count as a scan attempt when the mount is something
374+
// we've never known — a configured mount that's currently
375+
// offline is a legitimate listener, not an attacker probing
376+
// paths. Similarly, obvious non-stream prefetch paths
377+
// (favicon.ico, manifest.json, *.png …) don't deserve a ban.
378+
if s.looksLikeUnknownMount(mount) {
379+
host, _, _ := net.SplitHostPort(r.RemoteAddr)
380+
s.recordScanAttempt(host, mount)
381+
}
334382
http.NotFound(w, r)
335383
return
336384
}

0 commit comments

Comments
 (0)