package server
import (
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/projectdiscovery/gologger"
stringsutil "github.com/projectdiscovery/utils/strings"
)
// HTTPServer is a http server instance that listens both
// TLS and Non-TLS based servers.
type HTTPServer struct {
options *Options
tlsserver http.Server
nontlsserver http.Server
customBanner string
defaultResponse string
staticHandler http.Handler
}
type noopLogger struct {
}
func (l *noopLogger) Write(p []byte) (n int, err error) {
return 0, nil
}
// disableDirectoryListing disables directory listing on http.FileServer
func disableDirectoryListing(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") || r.URL.Path == "" {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// NewHTTPServer returns a new TLS & Non-TLS HTTP server.
func NewHTTPServer(options *Options) (*HTTPServer, error) {
server := &HTTPServer{options: options}
// If a static directory is specified, also serve it.
if options.HTTPDirectory != "" {
abs, _ := filepath.Abs(options.HTTPDirectory)
gologger.Info().Msgf("Loading directory (%s) to serve from : %s/s/", abs, strings.Join(options.Domains, ","))
server.staticHandler = http.StripPrefix("/s/", disableDirectoryListing(http.FileServer(http.Dir(options.HTTPDirectory))))
}
// If custom index, read the custom index file and serve it.
// Supports {DOMAIN} placeholders.
if options.HTTPIndex != "" {
abs, _ := filepath.Abs(options.HTTPIndex)
gologger.Info().Msgf("Using custom server index: %s", abs)
if data, err := os.ReadFile(options.HTTPIndex); err == nil {
server.customBanner = string(data)
}
}
// If default response file is specified, read it and serve for all requests.
// This takes priority over all other response options.
// Supports {DOMAIN} placeholders.
if options.DefaultHTTPResponseFile != "" {
abs, _ := filepath.Abs(options.DefaultHTTPResponseFile)
gologger.Info().Msgf("Using default HTTP response file for all requests: %s", abs)
if data, err := os.ReadFile(options.DefaultHTTPResponseFile); err == nil {
server.defaultResponse = string(data)
}
}
router := &http.ServeMux{}
router.Handle("/", server.logger(server.corsMiddleware(http.HandlerFunc(server.defaultHandler))))
router.Handle("/register", server.corsMiddleware(server.authMiddleware(http.HandlerFunc(server.registerHandler))))
router.Handle("/deregister", server.corsMiddleware(server.authMiddleware(http.HandlerFunc(server.deregisterHandler))))
router.Handle("/poll", server.corsMiddleware(server.authMiddleware(http.HandlerFunc(server.pollHandler))))
// Registered even when uploads are disabled, so that an upload request to a
// server without -upload gets a clean 501 rather than falling through to
// "/", where the logger middleware would persist the whole file body as an
// interaction record.
//
// Unlike the other authenticated routes this one is not wrapped in
// authMiddleware: it checks the token itself, because a request that fails
// the check has to be recorded as an interaction before the 401 and the
// middleware returns too early to allow that. -upload forces -auth with a
// random token, and the client refuses to send an upload to a server whose
// advertised capabilities say uploads are off, so anything unauthenticated
// arriving here is a target probing the endpoint -- exactly what we exist to
// record.
router.Handle("/upload", server.corsMiddleware(http.HandlerFunc(server.uploadHandler)))
// Hosted files are served outside the logger middleware, which would
// otherwise copy each file body into an interaction record; the handler
// records a body-elided interaction itself. No CORS: these are fetched by
// the target under test, not cross-origin by a browser.
router.Handle("/f/", http.HandlerFunc(server.serveUploadedFile))
if server.options.EnableMetrics {
router.Handle("/metrics", server.corsMiddleware(server.authMiddleware(http.HandlerFunc(server.metricsHandler))))
}
server.tlsserver = http.Server{Addr: formatAddress(options.ListenIP, options.HttpsPort), Handler: router, ErrorLog: log.New(&noopLogger{}, "", 0)}
server.nontlsserver = http.Server{Addr: formatAddress(options.ListenIP, options.HttpPort), Handler: router, ErrorLog: log.New(&noopLogger{}, "", 0)}
return server, nil
}
// ListenAndServe listens on http and/or https ports for the server.
func (h *HTTPServer) ListenAndServe(tlsConfig *tls.Config, httpAlive, httpsAlive chan bool) {
go func() {
if tlsConfig == nil {
return
}
h.tlsserver.TLSConfig = tlsConfig
httpsAlive <- true
if err := h.tlsserver.ListenAndServeTLS("", ""); err != nil {
gologger.Error().Msgf("Could not serve http on tls: %s\n", err)
httpsAlive <- false
}
}()
httpAlive <- true
if err := h.nontlsserver.ListenAndServe(); err != nil {
httpAlive <- false
gologger.Error().Msgf("Could not serve http: %s\n", err)
}
}
func (h *HTTPServer) logger(handler http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, _ := httputil.DumpRequest(r, true)
reqString := string(req)
gologger.Debug().Msgf("New HTTP request: \n\n%s\n", reqString)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, r)
resp, _ := httputil.DumpResponse(rec.Result(), true)
respString := string(resp)
for k, v := range rec.Header() {
w.Header()[k] = v
}
data := rec.Body.Bytes()
w.WriteHeader(rec.Result().StatusCode)
_, _ = w.Write(data)
var host string
// Check if the client's ip should be taken from a custom header (eg reverse proxy)
if originIP := r.Header.Get(h.options.OriginIPHeader); originIP != "" {
host = originIP
} else {
host, _, _ = net.SplitHostPort(r.RemoteAddr)
}
// if root-tld is enabled stores any interaction towards the main domain
if h.options.RootTLD {
requestHost := r.Host
if host, _, err := net.SplitHostPort(r.Host); err == nil {
requestHost = host
}
for _, domain := range h.options.Domains {
if h.options.RootTLD && stringsutil.HasSuffixI(requestHost, domain) {
ID := domain
host, _, _ := net.SplitHostPort(r.RemoteAddr)
interaction := &Interaction{
Protocol: httpProtocol(r),
UniqueID: r.Host,
FullId: r.Host,
RawRequest: reqString,
RawResponse: respString,
RemoteAddress: host,
Timestamp: time.Now(),
}
data, err := json.Marshal(interaction)
if err != nil {
gologger.Warning().Msgf("Could not encode root tld http interaction: %s\n", err)
} else {
gologger.Debug().Msgf("Root TLD HTTP Interaction: \n%s\n", string(data))
if err := h.options.Storage.AddInteractionWithId(ID, data); err != nil {
gologger.Warning().Msgf("Could not store root tld http interaction: %s\n", err)
}
}
}
}
}
if h.options.ScanEverywhere {
chunks := stringsutil.SplitAny(reqString, ".\n\t\"'")
for _, chunk := range chunks {
for part := range stringsutil.SlideWithLength(chunk, h.options.GetIdLength()) {
normalizedPart := strings.ToLower(part)
if h.options.isCorrelationID(normalizedPart) {
h.handleInteraction(r, normalizedPart, part, reqString, respString, host)
}
}
}
} else {
parts := strings.Split(r.Host, ".")
for i, part := range parts {
for partChunk := range stringsutil.SlideWithLength(part, h.options.GetIdLength()) {
normalizedPartChunk := strings.ToLower(partChunk)
if h.options.isCorrelationID(normalizedPartChunk) {
fullID := part
if i+1 <= len(parts) {
fullID = strings.Join(parts[:i+1], ".")
}
h.handleInteraction(r, normalizedPartChunk, fullID, reqString, respString, host)
}
}
}
}
}
}
func httpProtocol(r *http.Request) string {
if r.TLS != nil {
return "https"
}
return "http"
}
func (h *HTTPServer) handleInteraction(r *http.Request, uniqueID, fullID, reqString, respString, hostPort string) {
correlationID := uniqueID[:h.options.CorrelationIdLength]
interaction := &Interaction{
Protocol: httpProtocol(r),
UniqueID: uniqueID,
FullId: fullID,
RawRequest: reqString,
RawResponse: respString,
RemoteAddress: hostPort,
Timestamp: time.Now(),
}
data, err := json.Marshal(interaction)
if err != nil {
gologger.Warning().Msgf("Could not encode http interaction: %s\n", err)
} else {
gologger.Debug().Msgf("HTTP Interaction: \n%s\n", string(data))
if err := h.options.Storage.AddInteraction(correlationID, data); err != nil {
gologger.Warning().Msgf("Could not store http interaction: %s\n", err)
}
}
}
const banner = `
Interactsh Server
Interactsh is an open-source tool for detecting out-of-band interactions. It is a tool designed to detect vulnerabilities that cause external interactions.
If you notice any interactions from *.%s in your logs, it's possible that someone (internal security engineers, pen-testers, bug-bounty hunters) has been testing your application.
You should investigate the sites where these interactions were generated from, and if a vulnerability exists, examine the root cause and take the necessary steps to mitigate the issue.
`
func extractServerDomain(h *HTTPServer, req *http.Request) string {
if h.options.HeaderServer != "" {
return h.options.HeaderServer
}
var domain string
// use first domain as default (todo: should be extracted from certificate)
if len(h.options.Domains) > 0 {
// attempts to extract the domain name from host header
for _, configuredDomain := range h.options.Domains {
if stringsutil.HasSuffixI(req.Host, configuredDomain) {
domain = configuredDomain
break
}
}
// fallback to first domain in case of unknown host header
if domain == "" {
domain = h.options.Domains[0]
}
}
return domain
}
// defaultHandler is a handler for default collaborator requests
func (h *HTTPServer) defaultHandler(w http.ResponseWriter, req *http.Request) {
atomic.AddUint64(&h.options.Stats.Http, 1)
domain := extractServerDomain(h, req)
w.Header().Set("Server", domain)
if !h.options.NoVersionHeader {
w.Header().Set("X-Interactsh-Version", h.options.Version)
}
reflection := h.options.URLReflection(req.Host)
// If default response is set, serve it for all requests (highest priority)
if h.defaultResponse != "" {
_, _ = fmt.Fprint(w, strings.ReplaceAll(h.defaultResponse, "{DOMAIN}", domain))
return
}
if stringsutil.HasPrefixI(req.URL.Path, "/s/") && h.staticHandler != nil {
if h.options.DynamicResp && len(req.URL.Query()) > 0 {
values := req.URL.Query()
if headers := values["header"]; len(headers) > 0 {
for _, header := range headers {
if headerParts := strings.SplitN(header, ":", 2); len(headerParts) == 2 {
w.Header().Add(headerParts[0], headerParts[1])
}
}
}
if delay := values.Get("delay"); delay != "" {
if parsed, err := strconv.Atoi(delay); err == nil {
time.Sleep(time.Duration(parsed) * time.Second)
}
}
if status := values.Get("status"); status != "" {
if parsed, err := strconv.Atoi(status); err == nil {
w.WriteHeader(parsed)
}
}
}
h.staticHandler.ServeHTTP(w, req)
} else if req.URL.Path == "/" && reflection == "" {
if h.customBanner != "" {
_, _ = fmt.Fprint(w, strings.ReplaceAll(h.customBanner, "{DOMAIN}", domain))
} else {
_, _ = fmt.Fprintf(w, banner, domain)
}
} else if strings.EqualFold(req.URL.Path, "/robots.txt") {
_, _ = fmt.Fprintf(w, "User-agent: *\nDisallow: / # %s", reflection)
} else if stringsutil.HasSuffixI(req.URL.Path, ".json") {
_, _ = fmt.Fprintf(w, "{\"data\":\"%s\"}", reflection)
w.Header().Set("Content-Type", "application/json")
} else if stringsutil.HasSuffixI(req.URL.Path, ".xml") {
_, _ = fmt.Fprintf(w, "%s", reflection)
w.Header().Set("Content-Type", "application/xml")
} else {
if h.options.DynamicResp && (len(req.URL.Query()) > 0 || stringsutil.HasPrefixI(req.URL.Path, "/b64_body:")) {
writeResponseFromDynamicRequest(w, req)
return
}
_, _ = fmt.Fprintf(w, "%s", reflection)
}
}
// b64BodyPrefix marks a request path carrying a base64 encoded response body.
const b64BodyPrefix = "/b64_body:"
// decodeB64BodyPath decodes a /b64_body: path.
// HasPrefixI is case insensitive, so the payload offset is the prefix length
// rather than a second case-sensitive search. A single trailing slash is
// accepted as a terminator (nuclei templates use it); other slashes are left
// in place because they are valid in StdEncoding.
func decodeB64BodyPath(path string) []byte {
if !stringsutil.HasPrefixI(path, b64BodyPrefix) {
return nil
}
encoded := path[len(b64BodyPrefix):]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err == nil {
return decoded
}
if strings.HasSuffix(encoded, "/") {
decoded, _ = base64.StdEncoding.DecodeString(strings.TrimSuffix(encoded, "/"))
return decoded
}
return nil
}
// writeResponseFromDynamicRequest writes a response to http.ResponseWriter
// based on dynamic data from HTTP URL Query parameters.
//
// The following parameters are supported -
//
// body (response body)
// header (response header)
// status (response status code)
// delay (response time)
func writeResponseFromDynamicRequest(w http.ResponseWriter, req *http.Request) {
values := req.URL.Query()
if decoded := decodeB64BodyPath(req.URL.Path); decoded != nil {
_, _ = w.Write(decoded)
}
if headers := values["header"]; len(headers) > 0 {
for _, header := range headers {
if headerParts := strings.SplitN(header, ":", 2); len(headerParts) == 2 {
w.Header().Add(headerParts[0], headerParts[1])
}
}
}
if delay := values.Get("delay"); delay != "" {
parsed, _ := strconv.Atoi(delay)
time.Sleep(time.Duration(parsed) * time.Second)
}
if status := values.Get("status"); status != "" {
parsed, _ := strconv.Atoi(status)
w.WriteHeader(parsed)
}
if body := values.Get("body"); body != "" {
_, _ = w.Write([]byte(body))
}
if b64_body := values.Get("b64_body"); b64_body != "" {
decodedBytes, _ := base64.StdEncoding.DecodeString(string([]byte(b64_body)))
_, _ = w.Write(decodedBytes)
}
}
// RegisterRequest is a request for client registration to interactsh server.
type RegisterRequest struct {
// PublicKey is the public RSA Key of the client.
PublicKey string `json:"public-key"`
// SecretKey is the secret-key for correlation ID registered for the client.
SecretKey string `json:"secret-key"`
// CorrelationID is an ID for correlation with requests.
CorrelationID string `json:"correlation-id"`
}
// registerHandler is a handler for client register requests
func (h *HTTPServer) registerHandler(w http.ResponseWriter, req *http.Request) {
r := &RegisterRequest{}
if err := json.NewDecoder(req.Body).Decode(r); err != nil {
gologger.Warning().Msgf("Could not decode json body: %s\n", err)
jsonError(w, fmt.Sprintf("could not decode json body: %s", err), http.StatusBadRequest)
return
}
if err := h.options.Storage.SetIDPublicKey(r.CorrelationID, r.SecretKey, r.PublicKey); err != nil {
gologger.Warning().Msgf("Could not set id and public key for %s: %s\n", r.CorrelationID, err)
jsonError(w, fmt.Sprintf("could not set id and public key: %s", err), http.StatusBadRequest)
return
}
atomic.AddInt64(&h.options.Stats.Sessions, 1)
atomic.AddInt64(&h.options.Stats.SessionsTotal, 1)
// Capabilities ride along on the registration response so the client knows
// whether uploads are available without a second round trip. Older clients
// read only "message" and ignore the extra key.
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(&RegisterResponse{
Message: "registration successful",
Capabilities: h.capabilities(),
})
gologger.Debug().Msgf("Registered correlationID %s for key\n", r.CorrelationID)
}
// DeregisterRequest is a request for client deregistration to interactsh server.
type DeregisterRequest struct {
// CorrelationID is an ID for correlation with requests.
CorrelationID string `json:"correlation-id"`
// SecretKey is the secretKey for the interactsh client.
SecretKey string `json:"secret-key"`
}
// deregisterHandler is a handler for client deregister requests
func (h *HTTPServer) deregisterHandler(w http.ResponseWriter, req *http.Request) {
r := &DeregisterRequest{}
if err := json.NewDecoder(req.Body).Decode(r); err != nil {
gologger.Warning().Msgf("Could not decode json body: %s\n", err)
jsonError(w, fmt.Sprintf("could not decode json body: %s", err), http.StatusBadRequest)
return
}
if err := h.options.Storage.RemoveID(r.CorrelationID, r.SecretKey); err != nil {
gologger.Warning().Msgf("Could not remove id for %s: %s\n", r.CorrelationID, err)
jsonError(w, fmt.Sprintf("could not remove id: %s", err), http.StatusBadRequest)
return
}
// Deleted synchronously rather than queued: the cache eviction hook fired
// by RemoveID above only enqueues the directory, leaving a window in which
// a client that has just deregistered could still fetch its own hosted
// files. Blocking here is safe -- unlike the cache's event goroutine, this
// handler can afford the filesystem call -- and the deletion is idempotent,
// so the queued removal that follows is a no-op.
if h.options.UploadStore != nil {
h.options.UploadStore.removeSessionNow(r.CorrelationID)
}
if h.options.RootTLD {
for _, domain := range h.options.Domains {
_ = h.options.Storage.RemoveConsumer(domain, r.CorrelationID)
}
}
if h.options.Token != "" {
_ = h.options.Storage.RemoveConsumer(h.options.Token, r.CorrelationID)
}
jsonMsg(w, "deregistration successful", http.StatusOK)
gologger.Debug().Msgf("Deregistered correlationID %s for key\n", r.CorrelationID)
}
// PollResponse is the response for a polling request
type PollResponse struct {
Data []string `json:"data"`
Extra []string `json:"extra"`
AESKey string `json:"aes_key"`
TLDData []string `json:"tlddata,omitempty"`
}
// pollHandler is a handler for client poll requests
func (h *HTTPServer) pollHandler(w http.ResponseWriter, req *http.Request) {
ID := req.URL.Query().Get("id")
if ID == "" {
jsonError(w, "no id specified for poll", http.StatusBadRequest)
return
}
secret := req.URL.Query().Get("secret")
if secret == "" {
jsonError(w, "no secret specified for poll", http.StatusBadRequest)
return
}
data, aesKey, err := h.options.Storage.GetInteractions(ID, secret)
if err != nil {
gologger.Warning().Msgf("Could not get interactions for %s: %s\n", ID, err)
jsonError(w, fmt.Sprintf("could not get interactions: %s", err), http.StatusBadRequest)
return
}
// At this point the client is authenticated, so we return also the data related to the auth token
var tlddata, extradata []string
if h.options.RootTLD {
for _, domain := range h.options.Domains {
interactions, _ := h.options.Storage.GetInteractionsWithIdForConsumer(domain, ID)
// root domains interaction are not encrypted
tlddata = append(tlddata, interactions...)
}
}
if h.options.Token != "" {
// auth token interactions are not encrypted
extradata, _ = h.options.Storage.GetInteractionsWithIdForConsumer(h.options.Token, ID)
}
response := &PollResponse{Data: data, AESKey: aesKey, TLDData: tlddata, Extra: extradata}
if err := json.NewEncoder(w).Encode(response); err != nil {
gologger.Warning().Msgf("Could not encode interactions for %s: %s\n", ID, err)
jsonError(w, fmt.Sprintf("could not encode interactions: %s", err), http.StatusBadRequest)
return
}
gologger.Debug().Msgf("Polled %d interactions for %s correlationID\n", len(data), ID)
}
func (h *HTTPServer) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
allowOrigin := h.options.OriginURL
// When the allowed origin is the wildcard, reflect the request's Origin
// instead of returning "*". Browsers reject responses that combine
// "Access-Control-Allow-Origin: *" with "Access-Control-Allow-Credentials: true",
// so reflecting the origin keeps the default permissive while remaining valid.
if h.options.OriginURL == "*" {
if origin := req.Header.Get("Origin"); origin != "" {
allowOrigin = origin
}
// Response varies by Origin, so caches must key on it.
w.Header().Add("Vary", "Origin")
}
// Set CORS headers for the preflight request
if req.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
next.ServeHTTP(w, req)
})
}
func jsonBody(w http.ResponseWriter, key, value string, code int) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]interface{}{key: value})
}
func jsonError(w http.ResponseWriter, err string, code int) {
jsonBody(w, "error", err, code)
}
func jsonMsg(w http.ResponseWriter, err string, code int) {
jsonBody(w, "message", err, code)
}
func (h *HTTPServer) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if !h.checkToken(req) {
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, req)
})
}
func (h *HTTPServer) checkToken(req *http.Request) bool {
return !h.options.Auth || h.options.Auth && h.options.Token == req.Header.Get("Authorization")
}
// metricsHandler is a handler for /metrics endpoint
func (h *HTTPServer) metricsHandler(w http.ResponseWriter, req *http.Request) {
interactMetrics := h.options.Stats.snapshot()
interactMetrics.Cache = GetCacheMetrics(h.options)
interactMetrics.Cpu = GetCpuMetrics()
interactMetrics.Memory = GetMemoryMetrics()
interactMetrics.Network = GetNetworkMetrics()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
_ = json.NewEncoder(w).Encode(&interactMetrics)
}