From 3931d2fa1661d63beb8d968f004b9655295d1b43 Mon Sep 17 00:00:00 2001 From: Blazico Date: Wed, 8 Apr 2026 22:41:19 +0200 Subject: [PATCH 1/4] fix discord_id login failure --- database/user.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/database/user.go b/database/user.go index fe1c77f..70c37bf 100644 --- a/database/user.go +++ b/database/user.go @@ -18,14 +18,14 @@ const ( UpdateUserTable = `UPDATE users SET firstname = CASE WHEN $3 THEN $2 ELSE firstname END, lastname = CASE WHEN $5 THEN $4 ELSE lastname END, open_host = CASE WHEN $7 THEN $6 ELSE open_host END WHERE profile_id = $1` UpdateUserProfileID = `UPDATE users SET profile_id = $3 WHERE user_id = $1 AND gsbrcd = $2` UpdateUserNGDeviceID = `UPDATE users SET ng_device_id = $2 WHERE profile_id = $1` - GetUser = `SELECT user_id, gsbrcd, ng_device_id, email, unique_nick, firstname, lastname, has_ban, ban_reason, open_host, last_ingamesn, last_ip_address, discord_id, ban_moderator, ban_reason_hidden, ban_issued, ban_expires FROM users WHERE profile_id = $1` + GetUser = `SELECT users.user_id, users.gsbrcd, users.ng_device_id, users.email, users.unique_nick, users.firstname, users.lastname, users.has_ban, users.ban_reason, users.open_host, users.last_ingamesn, users.last_ip_address, player_data.discord_id, users.ban_moderator, users.ban_reason_hidden, users.ban_issued, users.ban_expires FROM users LEFT JOIN player_data ON player_data.profile_id = users.profile_id WHERE users.profile_id = $1` ClearProfileQuery = `DELETE FROM users WHERE profile_id = $1 RETURNING user_id, gsbrcd, email, unique_nick, firstname, lastname, open_host, last_ip_address, last_ingamesn` DoesUserExist = `SELECT EXISTS(SELECT 1 FROM users WHERE user_id = $1 AND gsbrcd = $2)` IsProfileIDInUse = `SELECT EXISTS(SELECT 1 FROM users WHERE profile_id = $1)` DeleteUserSession = `DELETE FROM sessions WHERE profile_id = $1` - GetUserProfileID = `SELECT profile_id, ng_device_id, email, unique_nick, firstname, lastname, open_host, discord_id, last_ip_address FROM users WHERE user_id = $1 AND gsbrcd = $2` + GetUserProfileID = `SELECT users.profile_id, users.ng_device_id, users.email, users.unique_nick, users.firstname, users.lastname, users.open_host, player_data.discord_id, users.last_ip_address FROM users LEFT JOIN player_data ON player_data.profile_id = users.profile_id WHERE users.user_id = $1 AND users.gsbrcd = $2` UpdateUserLastIPAddress = `UPDATE users SET last_ip_address = $2, last_ingamesn = $3 WHERE profile_id = $1` - UpdateDiscordID = `UPDATE users SET discord_id = $2 WHERE profile_id = $1` + UpdateDiscordID = `INSERT INTO player_data (profile_id, discord_id) VALUES ($1, $2) ON CONFLICT (profile_id) DO UPDATE SET discord_id = EXCLUDED.discord_id` UpdateUserBan = `UPDATE users SET has_ban = true, ban_issued = $2, ban_expires = $3, ban_reason = $4, ban_reason_hidden = $5, ban_moderator = $6, ban_tos = $7 WHERE profile_id = $1` DisableUserBan = `UPDATE users SET has_ban = false WHERE profile_id = $1` From 1812a02986019998a46ba9a9cd9f29bc242ff39c Mon Sep 17 00:00:00 2001 From: Blazico Date: Fri, 10 Apr 2026 21:44:19 +0200 Subject: [PATCH 2/4] Lifted device_id ban because of Dolphin NAND --- database/login.go | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/database/login.go b/database/login.go index 6be291d..4026555 100644 --- a/database/login.go +++ b/database/login.go @@ -14,26 +14,13 @@ import ( ) const ( - SearchUserBan = `WITH known_ng_device_ids AS ( - WITH RECURSIVE device_tree AS ( - SELECT unnest(ng_device_id) AS device_id - FROM users - WHERE ng_device_id && $1 - UNION - SELECT unnest(ng_device_id) - FROM users - JOIN device_tree dt - ON ng_device_id && array[dt.device_id] - ) SELECT array_agg(DISTINCT device_id) FROM device_tree - ) - SELECT has_ban, ban_tos, ng_device_id, ban_reason + SearchUserBan = `SELECT has_ban, ban_tos, ng_device_id, ban_reason FROM users WHERE has_ban = true - AND (profile_id = $2 - OR ng_device_id && (SELECT * FROM known_ng_device_ids) - OR last_ip_address = $3 - OR ($4 != '' AND last_ip_address = $4)) - AND (ban_expires IS NULL OR ban_expires > $5) + AND (profile_id = $1 + OR last_ip_address = $2 + OR ($3 != '' AND last_ip_address = $3)) + AND (ban_expires IS NULL OR ban_expires > $4) ORDER BY ban_tos DESC LIMIT 1` ) @@ -172,14 +159,14 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsb lastIPAddress = &emptyString } - // Find ban from device ID or IP address + // Find ban from profile ID or IP address var banExists bool var banTOS bool var bannedDeviceIdList []uint32 var banReason string timeNow := time.Now().UTC() - err = pool.QueryRow(ctx, SearchUserBan, user.NgDeviceId, user.ProfileId, ipAddress, *lastIPAddress, timeNow).Scan(&banExists, &banTOS, &bannedDeviceIdList, &banReason) + err = pool.QueryRow(ctx, SearchUserBan, user.ProfileId, ipAddress, *lastIPAddress, timeNow).Scan(&banExists, &banTOS, &bannedDeviceIdList, &banReason) if err != nil { if err != pgx.ErrNoRows { From 56b1e8803175eae7f1be9aa5497c1f2b53c744be Mon Sep 17 00:00:00 2001 From: Zakaria Hayaty Date: Wed, 22 Apr 2026 18:42:12 +0200 Subject: [PATCH 3/4] Added mii to pinfo --- api/pinfo.go | 69 ++++++++++++++++++++++++++++++++++++++--------- common/mii.go | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 12 deletions(-) diff --git a/api/pinfo.go b/api/pinfo.go index 9d41712..b4ec0df 100644 --- a/api/pinfo.go +++ b/api/pinfo.go @@ -1,10 +1,13 @@ package api import ( + "encoding/base64" + "encoding/binary" "encoding/json" "io" "net/http" "strconv" + "wwfc/common" "wwfc/database" ) @@ -14,9 +17,18 @@ type PinfoRequestSpec struct { } type PinfoResponse struct { - User database.User `json:"User"` - Success bool `json:"Success"` - Error string `json:"Error"` + Player PinfoPlayer `json:"player"` + Success bool `json:"success"` + Error string `json:"error"` +} + +type PinfoPlayer struct { + ProfileID uint32 `json:"profile_id"` + MiiName string `json:"mii_name"` + MiiData string `json:"mii_data"` + OpenHost bool `json:"open_host"` + Banned bool `json:"banned"` + DiscordID string `json:"discord_id"` } func HandlePinfo(w http.ResponseWriter, r *http.Request) { @@ -79,28 +91,61 @@ func handlePinfoImpl(r *http.Request) (PinfoResponse, int) { realUser, ok := database.GetProfile(pool, ctx, req.ProfileID) if !ok { return PinfoResponse{ - User: database.User{}, Success: false, Error: "Failed to find user in the database", }, http.StatusInternalServerError } + fullAccess := apiSecret == "" || req.Secret == apiSecret + user := realUser - if apiSecret == "" || req.Secret != apiSecret { + if !fullAccess { // Invalid or missing secret: return only the public-safe subset. user = database.User{ - ProfileId: realUser.ProfileId, - Restricted: realUser.Restricted, - BanReason: realUser.BanReason, - OpenHost: realUser.OpenHost, - LastInGameSn: realUser.LastInGameSn, - DiscordID: realUser.DiscordID, + ProfileId: realUser.ProfileId, + Restricted: realUser.Restricted, + OpenHost: realUser.OpenHost, + DiscordID: realUser.DiscordID, } } + miiName, miiData := getPinfoMiiData(realUser.ProfileId) + return PinfoResponse{ - User: user, + Player: PinfoPlayer{ + ProfileID: user.ProfileId, + MiiName: miiName, + MiiData: miiData, + OpenHost: user.OpenHost, + Banned: user.Restricted, + DiscordID: user.DiscordID, + }, Success: true, Error: "", }, http.StatusOK } + +func getPinfoMiiData(profileID uint32) (string, string) { + friendInfo := database.GetMKWFriendInfo(pool, ctx, profileID) + if friendInfo == "" { + return "", "" + } + + binaryData, err := base64.StdEncoding.DecodeString(friendInfo) + if err != nil || len(binaryData) < 0x4C { + return "", "" + } + + mii := common.RawMiiFromBytes(binaryData) + if mii.CalculateMiiCRC() != 0 { + return "", "" + } + + mii = mii.ClearMiiInfo() + miiName, err := common.GetWideString(mii.Data[0x2:0x16], binary.BigEndian) + if err != nil { + miiName = "" + } + + return miiName, base64.StdEncoding.EncodeToString(mii.Data[:]) +} diff --git a/common/mii.go b/common/mii.go index 2147cef..52e95fc 100644 --- a/common/mii.go +++ b/common/mii.go @@ -1,11 +1,23 @@ package common +import "encoding/binary" + // References: // https://wiibrew.org/wiki/Mii_Data // https://github.com/kiwi515/ogws/tree/master/src/RVLFaceLib type Mii [0x4C]byte +type RawMii struct { + Data [0x4C]byte +} + +func RawMiiFromBytes(data []byte) RawMii { + var miiData [0x4C]byte + copy(miiData[:], data[:0x4C]) + return RawMii{Data: miiData} +} + func (data Mii) RFLCalculateCRC() uint16 { crc := uint16(0) @@ -29,6 +41,29 @@ func (data Mii) RFLCalculateCRC() uint16 { return crc } +func (data RawMii) CalculateMiiCRC() uint16 { + crc := uint16(0) + + for _, val := range data.Data { + for j := 0; j < 8; j++ { + if crc&0x8000 != 0 { + crc <<= 1 + crc ^= 0x1021 + } else { + crc <<= 1 + } + + if val&0x80 != 0 { + crc ^= 0x1 + } + + val <<= 1 + } + } + + return crc +} + var officialMiiList = []uint64{ 0x80000000ECFF82D2, 0x80000001ECFF82D2, @@ -47,3 +82,42 @@ func RFLSearchOfficialData(id uint64) (bool, int) { return false, -1 } + +func SearchOfficialMiiData(id uint64) (bool, int) { + return RFLSearchOfficialData(id) +} + +// ClearMiiInfo clears Mii fields that should not be exposed publicly. +func (mii RawMii) ClearMiiInfo() RawMii { + if found, _ := SearchOfficialMiiData(binary.BigEndian.Uint64(mii.Data[0x18:0x20])); found { + return mii + } + + binary.BigEndian.PutUint32(mii.Data[0x18:0x1C], 0x80000000) + binary.BigEndian.PutUint32(mii.Data[0x1C:0x20], 0) + + hitNullTerminator := false + for i := 0; i < 20; i += 2 { + if hitNullTerminator { + mii.Data[0x2+i] = 0 + mii.Data[0x2+i+1] = 0 + } else if mii.Data[0x2+i] == 0 && mii.Data[0x2+i+1] == 0 { + hitNullTerminator = true + } + } + + for i := 0; i < 20; i++ { + mii.Data[0x36+i] = 0 + } + + mii.Data[0] &= ^byte(0x3F) + mii.Data[1] &= ^byte(0xE0) + + mii.Data[0x4A] = 0 + mii.Data[0x4B] = 0 + crc := mii.CalculateMiiCRC() + mii.Data[0x4A] = byte(crc >> 8) + mii.Data[0x4B] = byte(crc & 0xFF) + + return mii +} From 076b1e171fff5ada1595b762b97312b0f8b52fd9 Mon Sep 17 00:00:00 2001 From: Zakaria Hayaty Date: Wed, 22 Apr 2026 18:46:20 +0200 Subject: [PATCH 4/4] Security --- common/ip_banlist.go | 243 +++++++++++++++++++++++++++++++++++++++++++ main.go | 6 ++ nas/https.go | 12 +++ nas/main.go | 6 ++ 4 files changed, 267 insertions(+) create mode 100644 common/ip_banlist.go diff --git a/common/ip_banlist.go b/common/ip_banlist.go new file mode 100644 index 0000000..da184e7 --- /dev/null +++ b/common/ip_banlist.go @@ -0,0 +1,243 @@ +package common + +import ( + "bufio" + "net" + "net/netip" + "os" + "strings" + "sync" + "time" + "wwfc/logging" +) + +type ipBanEntry struct { + label string + from netip.Addr + to netip.Addr +} + +type ipBanList struct { + mutex sync.RWMutex + path string + lastModTime time.Time + lastSize int64 + lastError string + entries []ipBanEntry +} + +var globalIPBanList ipBanList +var ipBanListFilepath = "./ip_banlist.txt" + +func IsIPBanned(remoteAddr string) (bool, string) { + return globalIPBanList.isBanned(ipBanListFilepath, remoteAddr) +} + +func (l *ipBanList) isBanned(path string, remoteAddr string) (bool, string) { + addr, ok := parseRemoteIP(remoteAddr) + if !ok { + return false, "" + } + + l.ensureLoaded(path) + + l.mutex.RLock() + entries := l.entries + l.mutex.RUnlock() + + for _, entry := range entries { + if addr.BitLen() != entry.from.BitLen() { + continue + } + + if entry.from.Compare(addr) <= 0 && addr.Compare(entry.to) <= 0 { + return true, entry.label + } + } + + return false, "" +} + +func parseRemoteIP(remoteAddr string) (netip.Addr, bool) { + host := remoteAddr + + if addrPort, err := netip.ParseAddrPort(remoteAddr); err == nil { + return addrPort.Addr().Unmap(), true + } + + if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = splitHost + } + + host = strings.TrimPrefix(host, "[") + host = strings.TrimSuffix(host, "]") + + addr, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, false + } + + return addr.Unmap(), true +} + +func (l *ipBanList) ensureLoaded(path string) { + info, err := os.Stat(path) + if err != nil { + l.setError(path, err.Error()) + return + } + + l.mutex.RLock() + unchanged := l.path == path && l.lastModTime.Equal(info.ModTime()) && l.lastSize == info.Size() + l.mutex.RUnlock() + if unchanged { + return + } + + file, err := os.Open(path) + if err != nil { + l.setError(path, err.Error()) + return + } + defer file.Close() + + var entries []ipBanEntry + scanner := bufio.NewScanner(file) + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + line := scanner.Text() + + if idx := strings.Index(line, "#"); idx >= 0 { + line = line[:idx] + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + entry, ok := parseIPBanEntry(line) + if !ok { + logging.Warn("COMMON", "Ignoring invalid IP ban entry on line", lineNumber, "in", path, ":", line) + continue + } + + entries = append(entries, entry) + } + + if err := scanner.Err(); err != nil { + l.setError(path, err.Error()) + return + } + + l.mutex.Lock() + l.path = path + l.lastModTime = info.ModTime() + l.lastSize = info.Size() + l.lastError = "" + l.entries = entries + l.mutex.Unlock() + + logging.Notice("COMMON", "Loaded", len(entries), "IP ban entries from", path) +} + +func parseIPBanEntry(line string) (ipBanEntry, bool) { + if strings.Contains(line, "/") { + prefix, err := netip.ParsePrefix(line) + if err != nil { + return ipBanEntry{}, false + } + + prefix = prefix.Masked() + return ipBanEntry{ + label: line, + from: prefix.Addr().Unmap(), + to: lastAddrInPrefix(prefix), + }, true + } + + if strings.Contains(line, "-") { + rangeParts := strings.SplitN(line, "-", 2) + if len(rangeParts) != 2 { + return ipBanEntry{}, false + } + + start, err := netip.ParseAddr(strings.TrimSpace(rangeParts[0])) + if err != nil { + return ipBanEntry{}, false + } + + end, err := netip.ParseAddr(strings.TrimSpace(rangeParts[1])) + if err != nil { + return ipBanEntry{}, false + } + + start = start.Unmap() + end = end.Unmap() + if start.BitLen() != end.BitLen() || start.Compare(end) > 0 { + return ipBanEntry{}, false + } + + return ipBanEntry{ + label: line, + from: start, + to: end, + }, true + } + + addr, err := netip.ParseAddr(line) + if err != nil { + return ipBanEntry{}, false + } + + addr = addr.Unmap() + return ipBanEntry{ + label: line, + from: addr, + to: addr, + }, true +} + +func lastAddrInPrefix(prefix netip.Prefix) netip.Addr { + addr := prefix.Addr().Unmap() + bits := addr.BitLen() + ones := prefix.Bits() + + bytes := addr.AsSlice() + hostBits := bits - ones + for i := len(bytes) - 1; i >= 0 && hostBits > 0; i-- { + if hostBits >= 8 { + bytes[i] = 0xFF + hostBits -= 8 + continue + } + + bytes[i] |= byte((1 << hostBits) - 1) + hostBits = 0 + } + + last, ok := netip.AddrFromSlice(bytes) + if !ok { + return addr + } + + return last.Unmap() +} + +func (l *ipBanList) setError(path string, message string) { + l.mutex.Lock() + defer l.mutex.Unlock() + + if l.path == path && l.lastError == message { + return + } + + l.path = path + l.lastModTime = time.Time{} + l.lastSize = 0 + l.lastError = message + l.entries = nil + + logging.Warn("COMMON", "Unable to load IP ban list from", path, ":", message) +} diff --git a/main.go b/main.go index 98fec0a..9e9b7ee 100644 --- a/main.go +++ b/main.go @@ -426,6 +426,12 @@ func frontendListen(server serverInfo) { continue } + if blocked, rule := common.IsIPBanned(conn.RemoteAddr().String()); blocked { + logging.Warn("FRONTEND", "Blocked", aurora.BrightCyan(server.rpcName), "connection from", aurora.BrightCyan(conn.RemoteAddr().String()), "matching", aurora.Cyan(rule)) + conn.Close() + continue + } + if server.protocol == "tcp" { err := conn.(*net.TCPConn).SetKeepAlive(true) if err != nil { diff --git a/nas/https.go b/nas/https.go index 492b410..77a1fcd 100644 --- a/nas/https.go +++ b/nas/https.go @@ -84,6 +84,12 @@ func startHTTPSProxy(config common.Config) { } go func() { + if blocked, rule := common.IsIPBanned(conn.RemoteAddr().String()); blocked { + logging.Warn("NAS-TLS", "Blocked HTTPS connection from", aurora.BrightCyan(conn.RemoteAddr().String()), "matching", aurora.Cyan(rule)) + conn.Close() + return + } + moduleName := "NAS-TLS:" + conn.RemoteAddr().String() conn.SetDeadline(time.Now().UTC().Add(25 * time.Second)) @@ -237,6 +243,12 @@ func startHTTPSProxy(config common.Config) { } go func() { + if blocked, rule := common.IsIPBanned(conn.RemoteAddr().String()); blocked { + logging.Warn("NAS-TLS", "Blocked HTTPS connection from", aurora.BrightCyan(conn.RemoteAddr().String()), "matching", aurora.Cyan(rule)) + conn.Close() + return + } + // logging.Info("NAS-TLS", "Receiving HTTPS request from", aurora.BrightCyan(conn.RemoteAddr())) moduleName := "NAS-TLS:" + conn.RemoteAddr().String() diff --git a/nas/main.go b/nas/main.go index a3302bf..0935302 100644 --- a/nas/main.go +++ b/nas/main.go @@ -82,6 +82,12 @@ var regexGamestatsHost = regexp.MustCompile(`^([a-z\-]+\.)?gamestats2?\.gs\.`) var regexStage1URL = regexp.MustCompile(`^/w([0-9])$`) func handleRequest(w http.ResponseWriter, r *http.Request) { + if blocked, rule := common.IsIPBanned(r.RemoteAddr); blocked { + logging.Warn("NAS", "Blocked HTTP request from", aurora.BrightCyan(r.RemoteAddr), "matching", aurora.Cyan(rule)) + replyHTTPError(w, http.StatusForbidden, "403 Forbidden") + return + } + // Check for *.sake.gs.* or sake.gs.* if regexSakeHost.MatchString(r.Host) { // Redirect to the sake server