Files
AetherForge/server/internal/recon/parse.go
AetherForge 49c24e611c
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add recon form fingerprint library and SSRF canary mode.
Score crawl inputs by SSRF-prone field names and register ping-back canaries for operator paste confirmation.
2026-06-07 12:23:11 -07:00

221 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,
ID: id,
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
}