inou/portal/defense.go

249 lines
5.8 KiB
Go

package main
import (
"fmt"
"inou/lib"
"net/http"
"regexp"
"strings"
"time"
)
// Matches 16-char hex IDs (inou format) or standard UUIDs
var guidPattern = regexp.MustCompile(`[0-9a-fA-F]{16}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
const tarpitDuration = 30 * time.Second
var tarpitPatterns = []string{
// PHP/CMS
"php", "wp-", "xmlrpc", "phpmyadmin", "pma", "adminer",
"joomla", "drupal", "magento", "administrator",
// Config/Secrets
".env", ".git", ".svn", ".htaccess", ".htpasswd",
".aws", ".docker", ".bak", ".sql",
// Java/Spring
"actuator", "jolokia", "console",
// ColdFusion
"cfide", "cf_scripts",
// Shells/Exploits
"shell", "cgi-bin", "elfinder", "filemanager", "fckeditor",
// Infrastructure
"/manager/", "jenkins", "jmx-console", "web-console",
"solr", "telescope", "elmah",
// Network devices
"goform", "hnap", "stssys",
}
var validPaths = []string{
"/",
"/static/",
"/robots.txt",
"/sitemap.xml",
"/favicon.ico",
"/apple-touch-icon.png",
"/apple-touch-icon-precomposed.png",
"/app-ads.txt",
"/llms.txt",
"/send-code",
"/verify",
"/onboard",
"/logout",
"/dashboard",
"/connect",
"/install",
"/start",
"/invite",
"/set-lang",
"/privacy-policy",
"/security",
"/legal/dpa",
"/legal/terms",
"/demo",
"/oauth/authorize",
"/oauth/token",
"/oauth/userinfo",
"/oauth/revoke",
"/.well-known/",
"/register",
"/styleguide",
"/pricing",
"/faq",
"/dossier/",
"/viewer/",
"/data/",
"/image/",
"/contact-sheet.webp/",
"/download/",
// API - specific routes only (no broad /api prefix)
"/api/docs",
"/api/openapi.yaml",
"/api/token/",
"/api/version",
"/api/dossier", // covers /api/dossier and /api/dossiers
"/api/studies",
"/api/series",
"/api/slices",
"/api/labs/",
"/api/access",
"/api/audit",
"/api/entries",
"/api/trackers",
"/api/v1/auth/",
"/api/v1/dashboard",
"/api/v1/trackers",
"/api/v1/health",
"/api/v1/token",
"/api/v1/dossiers",
"/api/v1/images/",
"/api/v1/categories",
}
func isLocalIP(ip string) bool {
return strings.HasPrefix(ip, "192.168.")
}
func getIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return strings.Split(fwd, ",")[0]
}
return strings.Split(r.RemoteAddr, ":")[0]
}
func isValidPath(path string) bool {
if path == "/api" || path == "/mcp" {
return true
}
for _, p := range validPaths {
if p == "/" && path == "/" {
return true
}
if p != "/" && strings.HasPrefix(path, p) {
return true
}
}
return false
}
func shouldTarpit(path string) bool {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".js") && !strings.HasSuffix(lower, "viewer.js") {
return true
}
for _, pattern := range tarpitPatterns {
if strings.Contains(lower, pattern) {
return true
}
}
return false
}
// hasGUID checks if path or query contains a GUID-like token
// This indicates a legitimate client with invalid/expired credentials
func hasGUID(r *http.Request) bool {
// Check path
if guidPattern.MatchString(r.URL.Path) {
return true
}
// Check query params (token=xxx, dossier=xxx, etc.)
if guidPattern.MatchString(r.URL.RawQuery) {
return true
}
// Check Authorization header
if auth := r.Header.Get("Authorization"); auth != "" {
if guidPattern.MatchString(auth) {
return true
}
}
return false
}
// tarpit holds connection silent then closes - no HTTP response
func tarpit(w http.ResponseWriter, r *http.Request, label string) {
ip := getIP(r)
logAccess("\033[33m%s %s %s %s %s\033[0m",
time.Now().Format("2006-01-02 15:04:05"),
ip, label, r.Method, r.URL.RequestURI())
w.WriteHeader(444)
}
func defense(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
ip := getIP(r)
// Known bad patterns - tarpit immediately (unless GUID present)
if shouldTarpit(strings.ToLower(path)) {
if hasGUID(r) {
// Looks like a real client with bad/expired token - give proper error
logAccess("\033[33m%s %s INVALID_TOKEN %s %s\033[0m",
time.Now().Format("2006-01-02 15:04:05"),
ip, r.Method, r.URL.RequestURI())
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
tarpit(w, r, "TARPIT")
return
}
// MCP without Bearer token - not a legitimate client
if path == "/mcp" && !isLocalIP(ip) {
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
tarpit(w, r, "MCP_NO_AUTH")
return
}
}
// Unknown paths from non-local IPs
if !isValidPath(path) && !isLocalIP(ip) {
if hasGUID(r) {
// Looks like a real client with bad/expired token - give proper error
logAccess("\033[33m%s %s INVALID_TOKEN %s %s\033[0m",
time.Now().Format("2006-01-02 15:04:05"),
ip, r.Method, r.URL.RequestURI())
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
tarpit(w, r, "PROBE")
return
}
next.ServeHTTP(w, r)
})
}
type statusCapture struct {
http.ResponseWriter
status int
r *http.Request
}
func (s *statusCapture) WriteHeader(code int) {
s.status = code
if code == 404 && s.r.URL.Path != "/favicon.ico" {
ip := getIP(s.r)
lib.SendSignal(fmt.Sprintf("404: %s %s", ip, s.r.URL.Path))
}
s.ResponseWriter.WriteHeader(code)
}
func notifyOn404(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capture := &statusCapture{ResponseWriter: w, status: 200, r: r}
next.ServeHTTP(capture, r)
})
}
func normalizeQuery(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
raw := r.URL.RawQuery
raw = strings.ReplaceAll(raw, "&amp%253B", "&")
raw = strings.ReplaceAll(raw, "&amp%3B", "&")
raw = strings.ReplaceAll(raw, "&", "&")
raw = strings.ReplaceAll(raw, "%26amp%3B", "&")
raw = strings.ReplaceAll(raw, "%26amp;", "&")
r.URL.RawQuery = raw
next(w, r)
}
}