Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
POST /api/v1/recon/scan probes fleet ports from the server host, crawls owned HTTP targets, maps findings to spread lanes, and records optional oath ledger rows.
220 lines
5.1 KiB
Go
220 lines
5.1 KiB
Go
package recon
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
var urlFieldHints = []string{
|
|
"url", "link", "preview", "webhook", "fetch", "import", "oembed", "image",
|
|
}
|
|
|
|
var cmsPathMarkers = []struct {
|
|
path string
|
|
tag string
|
|
}{
|
|
{"/wp-admin", "wordpress"},
|
|
{"/wp-content", "wordpress"},
|
|
{"/strapi", "strapi"},
|
|
{"/graphql", "graphql"},
|
|
{"/admin/login", "admin_login"},
|
|
}
|
|
|
|
// ParseHTML extracts upload forms, URL fields, SSRF score, and CMS hints from HTML.
|
|
func ParseHTML(pageURL, body string) (fileInputs, multipart []FormFinding, urlFields []URLFieldFinding, ssrfScore int, cms []string) {
|
|
root, err := html.Parse(strings.NewReader(body))
|
|
if err != nil {
|
|
return nil, nil, nil, 0, cmsFromText(pageURL, body)
|
|
}
|
|
title := extractTitle(root)
|
|
_ = title
|
|
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if n.Type == html.ElementNode && n.Data == "form" {
|
|
form := parseForm(pageURL, n)
|
|
if form.HasFile {
|
|
fileInputs = append(fileInputs, form)
|
|
}
|
|
if form.Multipart {
|
|
multipart = append(multipart, form)
|
|
}
|
|
ssrfScore += scoreForm(form)
|
|
}
|
|
if n.Type == html.ElementNode && n.Data == "input" {
|
|
if field := parseURLField(pageURL, n); field != nil {
|
|
urlFields = append(urlFields, *field)
|
|
ssrfScore += 10
|
|
}
|
|
}
|
|
if n.Type == html.ElementNode && (n.Data == "textarea" || n.Data == "select") {
|
|
if field := parseURLFieldFromNamed(pageURL, attr(n, "name"), attr(n, "id"), attr(n, "placeholder")); field != nil {
|
|
urlFields = append(urlFields, *field)
|
|
ssrfScore += 8
|
|
}
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(root)
|
|
|
|
cms = cmsFromText(pageURL, body)
|
|
if ssrfScore > 100 {
|
|
ssrfScore = 100
|
|
}
|
|
return fileInputs, multipart, urlFields, ssrfScore, cms
|
|
}
|
|
|
|
func parseForm(pageURL string, form *html.Node) FormFinding {
|
|
f := FormFinding{
|
|
PageURL: pageURL,
|
|
Action: attr(form, "action"),
|
|
Method: strings.ToLower(attr(form, "method")),
|
|
Enctype: strings.ToLower(attr(form, "enctype")),
|
|
}
|
|
if f.Method == "" {
|
|
f.Method = "get"
|
|
}
|
|
if strings.Contains(f.Enctype, "multipart") {
|
|
f.Multipart = true
|
|
}
|
|
for c := form.FirstChild; c != nil; c = c.NextSibling {
|
|
collectFormFields(c, &f)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func collectFormFields(n *html.Node, f *FormFinding) {
|
|
if n.Type == html.ElementNode {
|
|
switch n.Data {
|
|
case "input", "textarea", "select":
|
|
name := attr(n, "name")
|
|
if name != "" {
|
|
f.Fields = append(f.Fields, name)
|
|
}
|
|
if n.Data == "input" && strings.EqualFold(attr(n, "type"), "file") {
|
|
f.HasFile = true
|
|
f.Multipart = true
|
|
}
|
|
if field := parseURLField(f.PageURL, n); field != nil {
|
|
f.Fields = append(f.Fields, field.Name+"("+field.Hint+")")
|
|
}
|
|
}
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
collectFormFields(c, f)
|
|
}
|
|
}
|
|
|
|
func parseURLField(pageURL string, n *html.Node) *URLFieldFinding {
|
|
name := attr(n, "name")
|
|
id := attr(n, "id")
|
|
placeholder := attr(n, "placeholder")
|
|
return parseURLFieldFromNamed(pageURL, name, id, placeholder)
|
|
}
|
|
|
|
func parseURLFieldFromNamed(pageURL, name, id, placeholder string) *URLFieldFinding {
|
|
joined := strings.ToLower(strings.Join([]string{name, id, placeholder}, " "))
|
|
for _, hint := range urlFieldHints {
|
|
if strings.Contains(joined, hint) {
|
|
label := name
|
|
if label == "" {
|
|
label = id
|
|
}
|
|
return &URLFieldFinding{
|
|
PageURL: pageURL,
|
|
Name: label,
|
|
Type: "text",
|
|
Hint: hint,
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scoreForm(f FormFinding) int {
|
|
score := 0
|
|
action := strings.ToLower(f.Action)
|
|
for _, hint := range urlFieldHints {
|
|
if strings.Contains(action, hint) {
|
|
score += 15
|
|
}
|
|
}
|
|
for _, field := range f.Fields {
|
|
lower := strings.ToLower(field)
|
|
for _, hint := range urlFieldHints {
|
|
if strings.Contains(lower, hint) {
|
|
score += 5
|
|
}
|
|
}
|
|
}
|
|
if f.HasFile {
|
|
score += 5
|
|
}
|
|
return score
|
|
}
|
|
|
|
func cmsFromText(pageURL, body string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
lowerURL := strings.ToLower(pageURL)
|
|
lowerBody := strings.ToLower(body)
|
|
add := func(tag string) {
|
|
if tag == "" || seen[tag] {
|
|
return
|
|
}
|
|
seen[tag] = true
|
|
out = append(out, tag)
|
|
}
|
|
for _, marker := range cmsPathMarkers {
|
|
if strings.Contains(lowerURL, marker.path) || strings.Contains(lowerBody, marker.path) {
|
|
add(marker.tag)
|
|
}
|
|
}
|
|
if strings.Contains(lowerBody, "strapi") {
|
|
add("strapi")
|
|
}
|
|
if strings.Contains(lowerBody, "wp-content") || strings.Contains(lowerBody, "wordpress") {
|
|
add("wordpress")
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractTitle(root *html.Node) string {
|
|
var title string
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil {
|
|
title = strings.TrimSpace(n.FirstChild.Data)
|
|
return
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(root)
|
|
return title
|
|
}
|
|
|
|
func attr(n *html.Node, key string) string {
|
|
for _, a := range n.Attr {
|
|
if a.Key == key {
|
|
return a.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readBodyLimited(r io.Reader, max int64) (string, error) {
|
|
buf := new(bytes.Buffer)
|
|
_, err := io.Copy(buf, io.LimitReader(r, max))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|