aboutsummaryrefslogtreecommitdiff
path: root/internal/net
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 17:48:26 -0400
committerhistoria <[not public]>2026-09-18 17:48:26 -0400
commit8fe9e3c3fef0049df82281a62afe35dda516dfe2 (patch)
treedd269f2c0710f334e19f00a191260ebc46284a90 /internal/net
parent194164a77eb4f2479b94c19e6f0e1278ab608781 (diff)
downloadthehouseoficarus-8fe9e3c3fef0049df82281a62afe35dda516dfe2.tar.gz
fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps
Diffstat (limited to 'internal/net')
-rw-r--r--internal/net/conn.go28
-rw-r--r--internal/net/server.go87
-rw-r--r--internal/net/web.go6
-rw-r--r--internal/net/wsconn.go8
4 files changed, 121 insertions, 8 deletions
diff --git a/internal/net/conn.go b/internal/net/conn.go
index 42b7c41..4aa4db4 100644
--- a/internal/net/conn.go
+++ b/internal/net/conn.go
@@ -11,6 +11,7 @@ import (
"strings"
"sync/atomic"
"time"
+ "unicode/utf8"
)
type Conn interface {
@@ -72,6 +73,7 @@ func (t *tcpConn) ReadMessage() (string, error) {
}
text := strings.TrimRight(string(line), "\r\n")
text = stripIAC(text)
+ text = truncateLine(text, maxLineLength)
if t.echoOff.Load() {
t.conn.Write([]byte("\r\n"))
}
@@ -82,6 +84,32 @@ func (t *tcpConn) Write(b []byte) (int, error) {
return t.conn.Write(b)
}
+// RemoteAddr reports the peer address for the login throttle.
+func (t *tcpConn) RemoteAddr() string {
+ if t.conn == nil {
+ return ""
+ }
+ return t.conn.RemoteAddr().String()
+}
+
+// truncateLine cuts s to at most max bytes without splitting a UTF-8 rune.
+// Both the telnet and websocket paths bound line length, and a byte-boundary
+// cut would otherwise emit an invalid trailing rune to the game.
+func truncateLine(s string, max int) string {
+ if max <= 0 || len(s) <= max {
+ return s
+ }
+ s = s[:max]
+ for len(s) > 0 {
+ r, size := utf8.DecodeLastRuneInString(s)
+ if r != utf8.RuneError || size > 1 {
+ break
+ }
+ s = s[:len(s)-1]
+ }
+ return s
+}
+
func (t *tcpConn) SetWriteDeadline(ts time.Time) error {
return t.conn.SetWriteDeadline(ts)
}
diff --git a/internal/net/server.go b/internal/net/server.go
index f56aebb..c818eef 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -141,6 +141,16 @@ func (sess *Session) AuthName() string {
return ""
}
+// RemoteAddr returns the client's network address when the transport can
+// report one, or "" for synthetic/test connections. It is immutable for the
+// life of the connection, so it is safe to read off the owner goroutine.
+func (sess *Session) RemoteAddr() string {
+ if rc, ok := sess.Conn.(interface{ RemoteAddr() string }); ok {
+ return rc.RemoteAddr()
+ }
+ return ""
+}
+
// sessionSeq is the source of Session.seq values. It is incremented under the
// adding Hub's mutex, so no extra atomicity is needed for correctness; atomic
// access keeps the package-level default counter safe if Hub.Add is ever
@@ -172,6 +182,11 @@ type Server struct {
// admits against maxConnections so a pre-auth connection flood cannot
// hold an unbounded number of reader goroutines, buffers, and outboxes.
conns atomic.Int64
+ // connsByIP bounds concurrent connections from one client address so a
+ // single source cannot exhaust the global cap with idle pre-auth
+ // sockets. Guarded by connsByIPMu.
+ connsByIPMu sync.Mutex
+ connsByIP map[string]int
// idleTimeout returns the read timeout armed before each message. It is a
// field so tests can drive the idle-drop path without waiting out the
// production windows; nil means sessionIdleTimeout.
@@ -204,6 +219,9 @@ const (
maxConnections = 1000
loginReadTimeout = 2 * time.Minute
idleReadTimeout = 30 * time.Minute
+ // maxConnectionsPerIP stops one source from exhausting the global
+ // maxConnections with idle pre-auth sockets.
+ maxConnectionsPerIP = 8
)
// admitConnection reserves a connection slot, returning false when the server
@@ -220,6 +238,52 @@ func (s *Server) releaseConnection() {
s.conns.Add(-1)
}
+// admitIP reserves a per-address connection slot, returning false when the
+// address already holds maxConnectionsPerIP. An empty address (synthetic
+// connections) is always admitted.
+func (s *Server) admitIP(addr string) bool {
+ ip := remoteHost(addr)
+ if ip == "" {
+ return true
+ }
+ s.connsByIPMu.Lock()
+ defer s.connsByIPMu.Unlock()
+ if s.connsByIP == nil {
+ s.connsByIP = make(map[string]int)
+ }
+ if s.connsByIP[ip] >= maxConnectionsPerIP {
+ return false
+ }
+ s.connsByIP[ip]++
+ return true
+}
+
+func (s *Server) releaseIP(addr string) {
+ ip := remoteHost(addr)
+ if ip == "" {
+ return
+ }
+ s.connsByIPMu.Lock()
+ defer s.connsByIPMu.Unlock()
+ if s.connsByIP[ip] <= 1 {
+ delete(s.connsByIP, ip)
+ return
+ }
+ s.connsByIP[ip]--
+}
+
+// remoteHost strips the port from a network address, returning it verbatim
+// when it has none.
+func remoteHost(addr string) string {
+ if addr == "" {
+ return ""
+ }
+ if host, _, err := net.SplitHostPort(addr); err == nil {
+ return host
+ }
+ return addr
+}
+
// sessionIdleTimeout returns the read timeout to arm for this session.
func (s *Server) sessionIdleTimeout(sess *Session) time.Duration {
if s.idleTimeout != nil {
@@ -531,8 +595,10 @@ func NewServer(cfg *config.Config) (*Server, error) {
if cfg.HTTP.Enabled {
s.httpServer = &http.Server{
- Addr: fmt.Sprintf(":%d", cfg.HTTP.Port),
- Handler: newHTTPMux(s),
+ Addr: fmt.Sprintf(":%d", cfg.HTTP.Port),
+ Handler: newHTTPMux(s),
+ ReadHeaderTimeout: 10 * time.Second,
+ IdleTimeout: 60 * time.Second,
}
}
@@ -546,8 +612,10 @@ func NewServer(cfg *config.Config) (*Server, error) {
MinVersion: tls.VersionTLS12,
}
s.httpsServer = &http.Server{
- Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port),
- Handler: newHTTPMux(s),
+ Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port),
+ Handler: newHTTPMux(s),
+ ReadHeaderTimeout: 10 * time.Second,
+ IdleTimeout: 60 * time.Second,
}
}
@@ -785,6 +853,13 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) {
}
defer s.releaseConnection()
+ remoteAddr := sess.RemoteAddr()
+ if !s.admitIP(remoteAddr) {
+ sess.Write("Too many connections from your address. Please try again later.\r\n")
+ return
+ }
+ defer s.releaseIP(remoteAddr)
+
sess.Write("\033[2J\033[H")
sess.Write(WelcomeBanner())
@@ -799,8 +874,8 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) {
log.Printf("session read error: %v", err)
return
}
- if len(line) > 1024 {
- line = line[:1024]
+ if len(line) > maxLineLength {
+ line = truncateLine(line, maxLineLength)
}
handler(sess, line)
}
diff --git a/internal/net/web.go b/internal/net/web.go
index 8353c8d..0ba0c98 100644
--- a/internal/net/web.go
+++ b/internal/net/web.go
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"strings"
+ "time"
"github.com/gorilla/websocket"
)
@@ -14,8 +15,9 @@ import (
var terminalHTML embed.FS
var upgrader = websocket.Upgrader{
- ReadBufferSize: 1024,
- WriteBufferSize: 1024,
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ HandshakeTimeout: 10 * time.Second,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
diff --git a/internal/net/wsconn.go b/internal/net/wsconn.go
index 29d1947..95f714c 100644
--- a/internal/net/wsconn.go
+++ b/internal/net/wsconn.go
@@ -27,6 +27,14 @@ func (w *wsConn) Write(b []byte) (int, error) {
return len(b), err
}
+// RemoteAddr reports the peer address for the login throttle.
+func (w *wsConn) RemoteAddr() string {
+ if w.conn == nil {
+ return ""
+ }
+ return w.conn.RemoteAddr().String()
+}
+
// SetWriteDeadline delegates to the underlying websocket connection, which
// supports deadlines natively.
func (w *wsConn) SetWriteDeadline(ts time.Time) error {