description = [[
Posts specially crafted strings to every form it
encounters and then searches through the website for those
strings to determine whether the payloads were successful.
]]

---
-- @usage nmap -p80 --script http-stored-xss.nse <target>
--
-- This script works in two phases.
-- 1) Posts specially crafted strings to every form it encounters.
-- 2) Crawls through the page searching for these strings.
--
-- If any string is reflected on some page without any proper
-- HTML escaping, it's a sign for potential XSS vulnerability.
--
-- @args http-stored-xss.formpaths The pages that contain
--       the forms to exploit. For example, {/upload.php,  /login.php}.
--       Default: nil (crawler mode on)
-- @args http-stored-xss.uploadspaths The pages that reflect
--       back POSTed data. For example, {/comments.php, /guestbook.php}.
--       Default: nil (Crawler mode on)
-- @args http-stored-xss.fieldvalues The script will try to
--       fill every field found in the form but that may fail due to
--       fields' restrictions. You can manually fill those fields using
--       this table. For example, {gender = "male", email = "foo@bar.com"}.
--       Default: {}
-- @args http-stored-xss.dbfile The path of a plain text file
--       that contains one XSS vector per line. Default: nil
--
-- @output
-- PORT   STATE SERVICE REASON
-- 80/tcp open  http    syn-ack
-- | http-stored-xss:
-- |   Found the following stored XSS vulnerabilities:
-- |
-- |      Payload: ghz>hzx
-- |    Uploaded on: /guestbook.php
-- |    Description: Unfiltered '>' (greater than sign). An indication of potential XSS vulnerability.
-- |      Payload: zxc'xcv
-- |    Uploaded on: /guestbook.php
-- |    Description: Unfiltered ' (apostrophe). An indication of potential XSS vulnerability.
-- |
-- |      Payload: ghz>hzx
-- |    Uploaded on: /posts.php
-- |    Description: Unfiltered '>' (greater than sign). An indication of potential XSS vulnerability.
-- |      Payload: hzx"zxc
-- |    Uploaded on: /posts.php
-- |_   Description: Unfiltered " (double quotation mark). An indication of potential XSS vulnerability.
--
-- @see http-dombased-xss.nse
-- @see http-phpself-xss.nse
-- @see http-xssed.nse
-- @see http-unsafe-output-escaping.nse

categories = {"intrusive", "exploit", "vuln"}
author = "George Chatzisofroniou"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

local http = require "http"
local io = require "io"
local string = require "string"
local httpspider = require "httpspider"
local shortport = require "shortport"
local stdnse = require "stdnse"
local table = require "table"

portrule = shortport.port_or_service( {80, 443}, {"http", "https"}, "tcp", "open")


-- A list of payloads.
--
-- You can manually add / remove your own payloads but make sure you
-- don't mess up, otherwise the script may succeed when it actually
-- hasn't.
--
-- Note, that more payloads will slow down your scan.
payloads = {

  -- Basic vectors. Each one is an indication of potential XSS vulnerability.
  { vector = 'ghz>hzx', description = "Unfiltered '>' (greater than sign). An indication of potential XSS vulnerability." },
  { vector = 'hzx"zxc', description = "Unfiltered \" (double quotation mark). An indication of potential XSS vulnerability." },
  { vector = 'zxc\'xcv', description = "Unfiltered ' (apostrophe). An indication of potential XSS vulnerability." },
}


-- Create customized requests for all of our payloads.
local makeRequests = function(host, port, submission, fields, fieldvalues)

  local postdata = {}
  for _, p in ipairs(payloads) do
    for __, field in ipairs(fields) do
      if field["type"] == "text" or field["type"] == "textarea" or field["type"] == "radio" or field["type"] == "checkbox" then

        local value = fieldvalues[field["name"]]
        if value == nil then
          value = p.vector
        end

        postdata[field["name"]] = value

      end
    end

    stdnse.debug2("Making a POST request to " .. submission .. ": ")
    for i, content in pairs(postdata) do
      stdnse.debug2(i .. ": " .. content)
    end
    local response = http.post(host, port, submission, { no_cache = true }, nil, postdata)
  end

end

local checkPayload = function(body, p)

  if (body:match(p)) then
    return true
  end

end

-- Check if the payloads were successful by checking the content of pages in the uploadspaths array.
local checkRequests = function(body, target)

  local output = {}
  for _, p in ipairs(payloads) do
    if checkPayload(body, p.vector) then
      local report = " Payload: " .. p.vector .. "\n\t Uploaded on: " .. target
      if p.description then
        report = report .. "\n\t Description: " .. p.description
      end
      table.insert(output, report)
    end
  end
  return output
end

local readFromFile = function(filename)
  local database = { }
  for l in io.lines(filename) do
    table.insert(payloads, { vector = l })
  end
end

action = function(host, port)

  local formpaths = stdnse.get_script_args("http-stored-xss.formpaths")
  local uploadspaths = stdnse.get_script_args("http-stored-xss.uploadspaths")
  local fieldvalues = stdnse.get_script_args("http-stored-xss.fieldvalues") or {}
  local dbfile = stdnse.get_script_args("http-stored-xss.dbfile")

  if dbfile then
    readFromFile(dbfile)
  end

  local returntable = {}
  local result

  local crawler = httpspider.Crawler:new( host, port, '/', { scriptname = SCRIPT_NAME,  no_cache = true } )

  if (not(crawler)) then
    return
  end

  crawler:set_timeout(10000)

  local index, k, target, response

  -- Phase 1. Crawls through the website and POSTs malicious payloads.
  while (true) do

    if formpaths then

      k, target = next(formpaths, index)
      if (k == nil) then
        break
      end
      response = http.get(host, port, target, { no_cache = true })
      target = host.name .. target
    else

      local status, r = crawler:crawl()
      -- if the crawler fails it can be due to a number of different reasons
      -- most of them are "legitimate" and should not be reason to abort
      if ( not(status) ) then
        if ( r.err ) then
          return stdnse.format_output(false, r.reason)
        else
          break
        end
      end

      target = tostring(r.url)
      response = r.response

    end

    if response.body then

      local forms = http.grab_forms(response.body)

      for i, form in ipairs(forms) do

        form = http.parse_form(form)

        if form and form.action then

          local action_absolute = string.find(form["action"], "https*://")

          -- Determine the path where the form needs to be submitted.
          local submission
          if action_absolute then
            submission = form["action"]
          else
            local path_cropped = string.match(target, "(.*/).*")
            path_cropped = path_cropped and path_cropped or ""
            submission = path_cropped..form["action"]
          end

          makeRequests(host, port, submission, form["fields"], fieldvalues)

        end
      end
    end
    if (index) then
      index = index + 1
    else
      index = 1
    end

  end

  local crawler = httpspider.Crawler:new( host, port, '/', { scriptname = SCRIPT_NAME } )
  local index

  -- Phase 2. Crawls through the website and searches for the special crafted strings that were POSTed before.
  while true do
    if uploadspaths then
      k, target = next(uploadspaths, index)
      if (k == nil) then
        break
      end
      response = http.get(host, port, target)
    else

      local status, r = crawler:crawl()
      -- if the crawler fails it can be due to a number of different reasons
      -- most of them are "legitimate" and should not be reason to abort
      if ( not(status) ) then
        if ( r.err ) then
          return stdnse.format_output(false, r.reason)
        else
          break
        end
      end

      target = tostring(r.url)
      response = r.response

    end

    if response.body then

      result = checkRequests(response.body, target)

      if next(result) then
        table.insert(returntable, result)
      end
    end
    if (index) then
      index = index + 1
    else
      index = 1
    end
  end

  if next(returntable) then
    table.insert(returntable, 1, "Found the following stored XSS vulnerabilities: ")
    return returntable
  else
    return "Couldn't find any stored XSS vulnerabilities."
  end
end
