packagehttpimport("bytes""context""crypto/tls""encoding/json""fmt""honeypot/internal/database""honeypot/internal/honeypot""honeypot/internal/logger""honeypot/internal/metrics"tlscert"honeypot/internal/tls""honeypot/internal/types""honeypot/internal/utils""io""io/fs""log/slog""mime/multipart""net/http""net/url""path/filepath""strings""sync""time""github.com/prometheus/client_golang/prometheus")const(HoneypotType=types.HoneypotTypeHTTPHoneypotLabel="HTTP"DefaultMaxBodySize=500*1024// 500 KBMaxBodyLogLength=10000MaxFilePreviewSize=1024CacheMaxAge="max-age=2592000"// 30 daysNginxServerHeader="nginx"ShutdownTimeout=3*time.Second)// contextKey is used for request context values.typecontextKeystring// dstPortKey is the context key for the destination port.constdstPortKeycontextKey="dstPort"var(// UsernameFieldNames contains common field names that might contain usernamesUsernameFieldNames=[]string{"log","user","username","login","email","account","accountname","userid","user_id","usr","name",}// PasswordFieldNames contains common field names that might contain passwordsPasswordFieldNames=[]string{"pwd","psd","password","passwd","pass","secret","pwd1","pwd2",}// StaticExtensions contains file extensions that should be skipped in loggingStaticExtensions=map[string]bool{".js":true,".css":true,".ico":true,".png":true,".jpg":true,".jpeg":true,".gif":true,".svg":true,".woff":true,".woff2":true,".ttf":true,".eot":true,}// MethodsWithBody contains HTTP methods that typically have request bodiesMethodsWithBody=map[string]bool{http.MethodPost:true,http.MethodPut:true,http.MethodPatch:true,"PROPFIND":true,"PROPPATCH":true,})// Config holds the configuration for the HTTP honeypot.typeConfigstruct{ListenAddrstringHTTPPorts[]uint16HTTPSPorts[]uint16MaxBodySizeint64CertConfigtlscert.CertConfigTrustedProxies[]string}// httpHoneypot implements the honeypot.Honeypot interface.typehttpHoneypotstruct{configConfiglogger*slog.LoggermaxBodySizeint64httpMethods*prometheus.CounterVec}// New creates a new HTTP honeypot instance.funcNew(cfgConfig)honeypot.Honeypot{maxBodySize:=cfg.MaxBodySizeifmaxBodySize==0{maxBodySize=DefaultMaxBodySize}h:=&httpHoneypot{config:cfg,maxBodySize:maxBodySize,}// Register user_agent and uri fields for top-N tracking// This is done during creation so fields are registered before log restorationlogger.RegisterTopNField("http","user_agent")logger.RegisterTopNField("http","uri")// Initialize and register HTTP-specific metricsh.httpMethods=prometheus.NewCounterVec(prometheus.CounterOpts{Name:"honeypot_http_methods_total",Help:"Total number of HTTP requests by method",},[]string{"method"},)// Register the metricsiferr:=logger.RegisterCollector(h.httpMethods);err!=nil{returnnil}returnh}// Name returns the name of this honeypot.func(h*httpHoneypot)Name()types.HoneypotType{returnHoneypotType}// Label returns the label of this honeypot.func(h*httpHoneypot)Label()string{returnHoneypotLabel}// Start starts the HTTP honeypot server.func(h*httpHoneypot)Start(ctxcontext.Context,l*slog.Logger)error{h.logger=lstaticRoot,err:=h.setupStaticFS()iferr!=nil{returnerr}mux:=h.setupRoutes(staticRoot)varwgsync.WaitGroup// Generate self-signed certificate once for all HTTPS servers if needed// This is used either as the main cert (no domain) or as fallback (with domain/ACME)varselfSignedCert*tls.Certificateiflen(h.config.HTTPSPorts)>0{varerrerrorselfSignedCert,err=h.generateSelfSignedCert(ctx)iferr!=nil{returnerr}}// Start HTTP servers on all configured portsfor_,port:=rangeh.config.HTTPPorts{ifport>0{server:=h.createServer(mux,port,nil)h.logServerStart(ctx,port,"http")h.startHTTPServer(ctx,server,&wg)h.setupGracefulShutdown(ctx,server)}}// Start HTTPS servers on all configured portsfor_,port:=rangeh.config.HTTPSPorts{ifport>0{h.startHTTPSServerIfConfigured(ctx,&wg,mux,port,selfSignedCert)}}wg.Wait()logger.LogInfo(h.logger,HoneypotType,"honeypot shutdown complete",nil)returnnil}// setupStaticFS prepares the static filesystem.func(h*httpHoneypot)setupStaticFS()(fs.FS,error){staticRoot,err:=fs.Sub(staticFS,"static")iferr!=nil{logger.LogError(h.logger,HoneypotType,"static_fs_failed",err,nil)returnnil,err}returnstaticRoot,nil}// createServer creates and configures an HTTP or HTTPS server.func(h*httpHoneypot)createServer(handlerhttp.Handler,portuint16,tlsConfig*tls.Config)*http.Server{// Wrap handler with port middleware so requests know which port they came in onwrappedHandler:=h.portMiddleware(handler,port)server:=&http.Server{Addr:utils.BuildAddress(h.config.ListenAddr,port),Handler:wrappedHandler,ReadHeaderTimeout:3*time.Second,}iftlsConfig!=nil{server.TLSConfig=tlsConfig}returnserver}// logServerStart logs server startup information for HTTP or HTTPS servers.func(h*httpHoneypot)logServerStart(ctxcontext.Context,portuint16,serverTypestring){logFields:=map[string]interface{}{"addr":utils.BuildAddress(h.config.ListenAddr,port),"port":port,}switchserverType{case"http":logFields["max_body_size"]=h.maxBodySizecase"https":iflen(h.config.HTTPPorts)>0{logFields["http_ports"]=h.config.HTTPPorts}}message:=serverType+" server listening"ifserverType=="http"{message="honeypot listening"}logger.LogInfo(h.logger,HoneypotType,message,[]any{"port",port,})}// startHTTPServer starts the HTTP server in a goroutine.func(h*httpHoneypot)startHTTPServer(ctxcontext.Context,server*http.Server,wg*sync.WaitGroup){wg.Add(1)gofunc(){deferwg.Done()iferr:=server.ListenAndServe();err!=nil&&err!=http.ErrServerClosed{logger.LogError(h.logger,HoneypotType,"http_server_failed",err,nil)}}()}// startHTTPSServerIfConfigured starts the HTTPS server on the specified port.func(h*httpHoneypot)startHTTPSServerIfConfigured(ctxcontext.Context,wg*sync.WaitGroup,mux*http.ServeMux,portuint16,selfSignedCert*tls.Certificate){wg.Add(1)gofunc(){deferwg.Done()iferr:=h.startHTTPSServer(ctx,mux,port,selfSignedCert);err!=nil&&err!=http.ErrServerClosed{logger.LogError(h.logger,HoneypotType,"https_server_failed",err,nil)}}()}// setupGracefulShutdown handles graceful shutdown of an HTTP/HTTPS server.func(h*httpHoneypot)setupGracefulShutdown(ctxcontext.Context,server*http.Server){gofunc(){<-ctx.Done()shutdownCtx,cancel:=context.WithTimeout(context.Background(),ShutdownTimeout)defercancel()iferr:=server.Shutdown(shutdownCtx);err!=nil{logger.LogError(h.logger,HoneypotType,"server_stop",err,nil)}}()}// resolveStaticPath maps request paths to static file paths.func(h*httpHoneypot)resolveStaticPath(pathstring)string{switchpath{case"/","","/index.php":return"/index.html"case"/wp-login.php","/admin/":return"/wp-login.html"default:returnpath}}// serveFile serves a static file with appropriate headers and method validation.func(h*httpHoneypot)serveFile(whttp.ResponseWriter,r*http.Request,filenamestring){// Method validation firstif!h.isValidHTTPMethod(r.Method){h.writeNginxError(w,http.StatusMethodNotAllowed)return}// WordPress PHP handlerifstrings.HasSuffix(filename,".php")&&(strings.HasPrefix(filename,"/wp-")||strings.HasPrefix(filename,"/wp-admin/")){switchfilename{case"/wp-login.php","/xmlrpc.php","/index.php":// handled normally (static or special handler)default:h.serveWordPressPHP(w,r)return}}// Resolve path inside static FSpath:="static"+filenameinfo,err:=fs.Stat(staticFS,path)iferr!=nil{// File does not existh.writeNginxError(w,http.StatusNotFound)return}// Directories without index.html → 403ifinfo.IsDir(){indexPath:=path+"/index.html"if_,err:=fs.Stat(staticFS,indexPath);err!=nil{h.writeNginxError(w,http.StatusForbidden)return}}// Match nginx headersh.setResponseHeaders(w)// HEAD should not write bodyifr.Method==http.MethodHead{w.WriteHeader(http.StatusOK)return}http.ServeFileFS(w,r,staticFS,path)}// isValidHTTPMethod checks if the HTTP method is valid.func(h*httpHoneypot)isValidHTTPMethod(methodstring)bool{validMethods:=map[string]bool{http.MethodGet:true,http.MethodHead:true,http.MethodPost:true,http.MethodPut:true,http.MethodPatch:true,http.MethodDelete:true,http.MethodOptions:true,http.MethodTrace:true,http.MethodConnect:true,}returnvalidMethods[method]}// setResponseHeaders sets standard response headers.func(h*httpHoneypot)setResponseHeaders(whttp.ResponseWriter){w.Header().Add("cache-control",CacheMaxAge)w.Header().Add("server",NginxServerHeader)}// logRequest logs an HTTP request with all relevant details.func(h*httpHoneypot)logRequest(r*http.Request){ifh.shouldSkipLogging(r.URL.Path){return}remoteHost,remotePort:=h.getRemoteAddr(r)vardstPortuint16ifport,ok:=r.Context().Value(dstPortKey).(uint16);ok{dstPort=port}fields:=h.buildRequestFields(r)h.addHeadersToFields(r,fields)h.logRequestBodyIfNeeded(r,fields)// Check if both username and password are presentusername,hasUsername:=fields["username"].(string)password,hasPassword:=fields["password"].(string)hasUsername=hasUsername&&username!=""hasPassword=hasPassword&&password!=""// If both username and password are present, log as auth_attempt instead of requestvareventtypes.LogEventifhasUsername&&hasPassword{event=types.LogEvent{Type:HoneypotType,Event:types.EventAuthAttempt,RemoteAddr:remoteHost,RemotePort:remotePort,DstPort:dstPort,Fields:fields,// Include all fields including username and password}}else{event=types.LogEvent{Type:HoneypotType,Event:types.EventRequest,RemoteAddr:remoteHost,RemotePort:remotePort,DstPort:dstPort,Fields:fields,}}logger.LogEvent(h.logger,event)// Record HTTP-specific metricsh.recordHTTPMetrics(event)}// getRemoteAddr resolves the actual remote address, considering proxy headers if the request is from a trusted source.func(h*httpHoneypot)getRemoteAddr(r*http.Request)(string,uint16){remoteHost,remotePort:=utils.SplitAddr(r.RemoteAddr,h.logger)// Check if the remote host is trustedisTrusted:=remoteHost=="127.0.0.1"||remoteHost=="::1"if!isTrusted{for_,proxy:=rangeh.config.TrustedProxies{ifremoteHost==proxy{isTrusted=truebreak}}}// If request is from a trusted proxy, check for proxy headersifisTrusted{// Try X-Real-Ip firstifrealIP:=r.Header.Get("X-Real-Ip");realIP!=""{returnrealIP,remotePort}// Fallback to X-Forwarded-Forifxff:=r.Header.Get("X-Forwarded-For");xff!=""{// X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2...// We take the first one which is usually the original client.ips:=strings.Split(xff,",")iflen(ips)>0{returnstrings.TrimSpace(ips[0]),remotePort}}}returnremoteHost,remotePort}// shouldSkipLogging returns true if the path should not be logged (static assets).func(h*httpHoneypot)shouldSkipLogging(pathstring)bool{ext:=filepath.Ext(path)returnStaticExtensions[ext]}// buildRequestFields creates the base fields for request logging.func(h*httpHoneypot)buildRequestFields(r*http.Request)map[string]interface{}{returnmap[string]interface{}{"method":r.Method,"host":r.Host,"uri":r.URL.Path,"query":r.URL.RawQuery,}}// addHeadersToFields adds HTTP headers to the log fields.func(h*httpHoneypot)addHeadersToFields(r*http.Request,fieldsmap[string]interface{}){iflen(r.Header)==0{return}headers:=make(map[string]string)fork,v:=ranger.Header{iflen(v)>0{headers[k]=v[0]}}fields["headers"]=headers}// logRequestBodyIfNeeded logs the request body if the method typically has a body.func(h*httpHoneypot)logRequestBodyIfNeeded(r*http.Request,fieldsmap[string]interface{}){ifr.Body==nil||!MethodsWithBody[r.Method]{return}bodyBytes,err:=h.readAndRestoreBody(r)iferr!=nil{return}iflen(bodyBytes)==0{return}contentType:=r.Header.Get("Content-Type")h.parseBodyByContentType(bodyBytes,contentType,fields)}// readAndRestoreBody reads the request body and restores it for further processing.func(h*httpHoneypot)readAndRestoreBody(r*http.Request)([]byte,error){bodyBytes,err:=io.ReadAll(r.Body)// Always restore the body, even if there was an error (may have partial data)r.Body=io.NopCloser(bytes.NewReader(bodyBytes))iferr!=nil{returnnil,err}returnbodyBytes,nil}// parseBodyByContentType parses the body based on its content type.func(h*httpHoneypot)parseBodyByContentType(bodyBytes[]byte,contentTypestring,fieldsmap[string]interface{}){fields["content_length"]=len(bodyBytes)fields["content_type"]=contentTypeswitch{casestrings.HasPrefix(contentType,"multipart/form-data"):h.parseMultipartForm(bodyBytes,contentType,fields)casecontentType=="application/x-www-form-urlencoded":h.parseFormURLEncoded(bodyBytes,fields)casestrings.HasPrefix(contentType,"application/json"):h.parseJSON(bodyBytes,fields)default:h.logBodyAsString(bodyBytes,fields)}}// logBodyAsString logs the body as a string, truncating if necessary.func(h*httpHoneypot)logBodyAsString(bodyBytes[]byte,fieldsmap[string]interface{}){bodyStr:=string(bodyBytes)iflen(bodyStr)>MaxBodyLogLength{bodyStr=bodyStr[:MaxBodyLogLength]+"... (truncated)"}fields["body"]=bodyStrfields["body_size"]=len(bodyBytes)}// parseMultipartForm parses a multipart/form-data request body.func(h*httpHoneypot)parseMultipartForm(bodyBytes[]byte,contentTypestring,fieldsmap[string]interface{}){boundary:=h.extractMultipartBoundary(contentType)ifboundary==""{// no boundary found, skip parsingreturn}reader:=multipart.NewReader(bytes.NewReader(bodyBytes),boundary)formData,files:=h.parseMultipartParts(reader,fields)iflen(formData)>0{fields["form_data"]=formDatah.extractCredentials(formData,fields)}iflen(files)>0{fields["uploaded_files"]=files}}// extractMultipartBoundary extracts the boundary parameter from Content-Type header.func(h*httpHoneypot)extractMultipartBoundary(contentTypestring)string{parts:=strings.Split(contentType,"boundary=")iflen(parts)<2{return""}returnstrings.Trim(parts[1],`"`)}// parseMultipartParts parses all parts from a multipart reader.func(h*httpHoneypot)parseMultipartParts(reader*multipart.Reader,fieldsmap[string]interface{})(map[string]interface{},[]string){formData:=make(map[string]interface{})files:=[]string{}for{part,err:=reader.NextPart()iferr==io.EOF{break}iferr!=nil{// error reading part, skip parsingbreak}ifpart.FileName()!=""{fileName:=part.FileName()files=append(files,fileName)h.parseMultipartFile(part,formData)}else{h.parseMultipartField(part,formData)}}returnformData,files}// parseMultipartFile parses a file upload part.func(h*httpHoneypot)parseMultipartFile(part*multipart.Part,formDatamap[string]interface{}){partName:=part.FormName()fileName:=part.FileName()fileContent:=make([]byte,MaxFilePreviewSize)n,_:=part.Read(fileContent)ifn>0{formData[partName+"_file"]=map[string]interface{}{"filename":fileName,"size":n,"preview":string(fileContent[:n]),}}}// parseMultipartField parses a form field part.func(h*httpHoneypot)parseMultipartField(part*multipart.Part,formDatamap[string]interface{}){partName:=part.FormName()fieldValue,err:=io.ReadAll(part)iferr==nil{formData[partName]=string(fieldValue)}}// parseFormURLEncoded parses an application/x-www-form-urlencoded request body.func(h*httpHoneypot)parseFormURLEncoded(bodyBytes[]byte,fieldsmap[string]interface{}){values,err:=url.ParseQuery(string(bodyBytes))iferr!=nil{return}formData:=make(map[string]interface{})fork,v:=rangevalues{iflen(v)>0{formData[k]=v[0]}}iflen(formData)>0{fields["form_data"]=formDatah.extractCredentials(formData,fields)}}// extractCredentials extracts username and password from form data and sets them// as top-level fields for consistency with SSH honeypot logging.func(h*httpHoneypot)extractCredentials(formDatamap[string]interface{},fieldsmap[string]interface{}){h.extractUsername(formData,fields)h.extractPassword(formData,fields)}// extractUsername extracts username from form data.func(h*httpHoneypot)extractUsername(formDatamap[string]interface{},fieldsmap[string]interface{}){h.extractField(formData,fields,UsernameFieldNames,"username")}// extractPassword extracts password from form data.func(h*httpHoneypot)extractPassword(formDatamap[string]interface{},fieldsmap[string]interface{}){h.extractField(formData,fields,PasswordFieldNames,"password")}// extractField extracts a field value from form data using a list of possible field names.func(h*httpHoneypot)extractField(formDatamap[string]interface{},fieldsmap[string]interface{},fieldNames[]string,fieldKeystring){for_,fieldName:=rangefieldNames{ifval,ok:=formData[fieldName];ok{ifstrVal,ok:=val.(string);ok&&strVal!=""{fields[fieldKey]=strValreturn}}}}// parseJSON parses JSON body and extracts username/password if present.func(h*httpHoneypot)parseJSON(bodyBytes[]byte,fieldsmap[string]interface{}){varjsonDatamap[string]interface{}iferr:=json.Unmarshal(bodyBytes,&jsonData);err==nil{h.extractCredentials(jsonData,fields)}h.logBodyAsString(bodyBytes,fields)}// recordHTTPMetrics records HTTP-specific metrics.func(h*httpHoneypot)recordHTTPMetrics(etypes.LogEvent){ife.Fields==nil{return}// Record HTTP methodifmethod,ok:=e.Fields["method"].(string);ok&&method!=""&&h.httpMethods!=nil{h.httpMethods.WithLabelValues(metrics.SanitizeUTF8(method)).Inc()}}funclooksLikeBearerToken(tokenstring)bool{// JWT (header.payload.signature)ifstrings.Count(token,".")==2{returntrue}// Common real-world token prefixesprefixes:=[]string{"ghp_",// GitHub"github_pat_",// GitHub fine-grained"glpat-",// GitLab"AKIA",// AWS access key"ya29.",// Google OAuth}for_,p:=rangeprefixes{ifstrings.HasPrefix(token,p){returntrue}}// Long random-looking tokensiflen(token)>=32{returntrue}returnfalse}func(h*httpHoneypot)GetScores(db*database.Database,intervalstring)honeypot.ScoreMap{// get scores for Authorization headers where the value starts with "Bearer " or "Basic "rows,err:=db.DB.Query(fmt.Sprintf(` SELECT remote_addr, COUNT(*) as authorization_count
FROM honeypot_events
WHERE type = 'http'
AND fields.authorization IS NOT NULL
AND (fields.authorization LIKE 'Bearer %%' OR fields.authorization LIKE 'Basic %%')
AND time >= now() - INTERVAL %s
GROUP BY remote_addr`,interval))iferr!=nil{returnhoneypot.ScoreMap{}}deferrows.Close()scoresAuthorization:=honeypot.ScoreMap{}forrows.Next(){varipstringvarauthorizationCountuinterr:=rows.Scan(&ip,&authorizationCount)iferr!=nil{returnhoneypot.ScoreMap{}}scoresAuthorization[ip]=honeypot.Score{Score:authorizationCount*100,Tags:[]types.Tag{types.TagAuthAttempt}}}// get scores for malicious payloads fields contain "wget" or "curl"rows,err=db.DB.Query(fmt.Sprintf(` SELECT remote_addr, COUNT(*) as malicious_count
FROM honeypot_events
WHERE type = 'http'
AND (fields.body ILIKE '%%wget%%' OR fields.body ILIKE '%%curl%%'
OR fields.form_data::TEXT ILIKE '%%wget%%' OR fields.form_data::TEXT ILIKE '%%curl%%')
AND time >= now() - INTERVAL %s
GROUP BY remote_addr`,interval))iferr!=nil{returnhoneypot.ScoreMap{}}deferrows.Close()scoresMalicious:=honeypot.ScoreMap{}forrows.Next(){varipstringvarmaliciousCountuinterr:=rows.Scan(&ip,&maliciousCount)iferr!=nil{returnhoneypot.ScoreMap{}}scoresMalicious[ip]=honeypot.Score{Score:maliciousCount*200,Tags:[]types.Tag{types.TagMalware}}}// merge scores for uri enumerationsrows,err=db.DB.Query(fmt.Sprintf(` SELECT remote_addr, COUNT(*) as uri_enumeration_count
FROM honeypot_events
WHERE type = 'http'
AND (fields.uri ILIKE '%%/.env%%' OR fields.uri ILIKE '%%/.git%%')
AND time >= now() - INTERVAL %s
GROUP BY remote_addr`,interval))iferr!=nil{returnhoneypot.ScoreMap{}}deferrows.Close()scoresURIEnumeration:=honeypot.ScoreMap{}forrows.Next(){varipstringvaruriEnumerationCountuinterr:=rows.Scan(&ip,&uriEnumerationCount)iferr!=nil{returnhoneypot.ScoreMap{}}scoresURIEnumeration[ip]=honeypot.Score{Score:uriEnumerationCount*150,Tags:[]types.Tag{types.TagInfoStealing}}}// merge scoresreturnhoneypot.MergeScores(scoresAuthorization,scoresMalicious,scoresURIEnumeration)}func(h*httpHoneypot)Ports()[]uint16{ports:=append([]uint16{},h.config.HTTPPorts...)ports=append(ports,h.config.HTTPSPorts...)returnports}