Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Score crawl inputs by SSRF-prone field names and register ping-back canaries for operator paste confirmation.
221 lines
5.1 KiB
Go
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
|
|
}
|