--- Expat Parser wrapper which protects against XML threats.
--[[ inspired by;

- https://docs.sensedia.com/en/api-platform-guide/4.2.x.x/interceptors/security_xml-threat-protection.html
- https://tech.forums.softwareag.com/t/xml-threat-protection/236959
- https://docs.mulesoft.com/api-manager/2.x/apply-configure-xml-threat-task
- https://github.com/Trust1Team/kong-plugin-xml-threat-protection

]]


local lxp = require "lxp"


local threat = {}

local defaults = {
	depth = 50,				-- depth of tags

	-- counts
	maxChildren = 100,		-- max number of children (DOM2;  Element, Text, Comment,
							-- ProcessingInstruction, CDATASection). NOTE: adjacent text/CDATA
							-- sections are counted as 1 (so text-cdata-text-cdata is 1 child).
	maxAttributes = 100,	-- max number of attributes (including default ones), if not parsing
							-- namespaces, then the namespaces will be counted as attributes.
	maxNamespaces = 20,		-- max number of namespaces defined on a tag

	-- size limits
	document = 10*1024*1024,	-- 10 mb; size of entire document in bytes
	buffer = 1024*1024,			-- 1 mb; size of the unparsed buffer
	comment = 1024,				-- 1 kb; size of comment in bytes
	localName = 1024,			-- 1 kb; size of localname (or full name if not parsing namespaces) in bytes,
								-- applies to tags and attributes
	prefix = 1024,				-- 1 kb; size of prefix in bytes (only if parsing namespaces), applies to
								-- tags and attributes
	namespaceUri = 1024,		-- 1 kb; size of namespace uri in bytes (only if parsing namespaces)
	attribute = 1024*1024,		-- 1 mb; size of attribute value in bytes
	text = 1024*1024,			-- 1 mb; text inside tags (counted over all adjacent text/CDATA)
	PITarget = 1024,			-- 1 kb; size of processing instruction target in bytes
	PIData = 1024,				-- 1 kb; size of processing instruction data in bytes
	entityName = 1024,			-- 1 kb; size of entity name in EntityDecl in bytes
	entity = 1024,				-- 1 kb; size of entity value in EntityDecl in bytes
	entityProperty = 1024,		-- 1 kb; size of systemId, publicId, or notationName in EntityDecl in bytes
}



--- Creates a parser that implements xml threat protection.
function threat.new(callbacks, separator, merge_character_data)
	assert(type(callbacks) == "table", "expected arg #1 to be a table with callbacks")
	local checks = callbacks.threat
	assert(type(checks) == "table", "expected entry 'threat' in callbacks table to be a table with checks")
	if checks.maxNamespaces or checks.prefix or checks.namespaceUri then
		assert(separator ~= nil, "expected separator to be set when checking maxNamespaces, prefix, and/or namespaceUri")
	end

	-- apply defaults
	for setting, value in pairs(defaults) do
		checks[setting] = checks[setting] or value
	end
	if separator == nil then
		checks.maxNamespaces = nil
		checks.prefix = nil
		checks.namespaceUri = nil
	end

	do -- add missing callbacks so we get all checks
		local callbacks_def = {
			"CharacterData",
			"Comment",
			--"Default",
			--"DefaultExpand",
			"EndCdataSection",
			"EndDoctypeDecl",
			"EndElement",
			"EndNamespaceDecl",
			"ExternalEntityRef",
			"NotStandalone",
			"NotationDecl",
			"ProcessingInstruction",
			"StartCdataSection",
			"StartDoctypeDecl",
			"StartElement",
			"StartNamespaceDecl",
			--"UnparsedEntityDecl", -- superseeded by EntityDecl
			"EntityDecl",
			"AttlistDecl",
			"ElementDecl",
			"SkippedEntity",
			"XmlDecl",
		}
		local nop = function() end
		for _, cbname in ipairs(callbacks_def) do
			if not callbacks[cbname] then
				callbacks[cbname] = nop
			end
		end
	end

	local parser -- the standard expat parser; forward declaration
	local p = {} -- the parser object to return
	local new_cbs = {} -- new callbacks
	local threat_error_data -- error data to return

	local function threat_error(msg)
		threat_error_data = { msg, parser:pos() } -- total 4 results
		parser:stop()
	end

	function p:close()
		local ok, err = parser:close()
		return ok == parser and p or ok, err
	end
	function p:getbase() return parser:getbase() end
	function p:getcallbacks() return callbacks end
	function p:pos() return parser:pos() end
	function p:getcurrentbytecount() return parser:getcurrentbytecount() end
	function p:setbase(base)
		local ok, err = parser:setbase(base)
		return ok == parser and p or ok, err
	end
	function p:setencoding(encoding)
		local ok, err = parser:setencoding(encoding)
		return ok == parser and p or ok, err
	end
	function p:stop()
		local ok, err = parser:stop()
		return ok == parser and p or ok, err
	end
	function p:returnnstriplet(enable)
		local ok, err = parser:returnnstriplet(enable)
		return ok == parser and p or ok, err
	end
	do
		local size = 0
		function p:parse(s)
			size = size + #(s or "")
			if checks.document and size > checks.document then
				return nil, "document too large"
			end

			local a,b,c,d,e = parser:parse(s)
			if threat_error_data then
				return nil, threat_error_data[1], threat_error_data[2], threat_error_data[3], threat_error_data[4]
			end
			if checks.buffer and size - parser:pos() > checks.buffer then
				return nil, "unparsed buffer too large"
			end
			if a == parser then
				return p,b,c,d,e
			end
			return a,b,c,d,e
		end
	end

	-- stats to track
	local context = {			-- current context
		children = 0,
	}
	local stack = { context }	-- tracking depth of context

	for key, cb in pairs(callbacks) do
		local ncb

		if key == "CharacterData" then
			ncb = function(parser, data)
				local l = context.charcount
				if not l then
					l = #data
					if checks.maxChildren then
						context.children = context.children + 1
						if context.children > checks.maxChildren then
							return threat_error("too many children")
						end
					end
				else
					l = l + #data
				end
				if checks.text and l > checks.text then
					return threat_error("text/CDATA node(s) too long")
				end
				context.charcount = l
				return callbacks.CharacterData(p, data)
			end

		elseif key == "Comment" then
			ncb = function(parser, data)
				if checks.comment and #data > checks.comment then
					return threat_error("comment too long")
				end

				context.children = context.children + 1
				if checks.maxChildren and context.children > checks.maxChildren then
					return threat_error("too many children")
				end

				context.charcount = nil -- reset text-length counter

				return callbacks.Comment(p, data)
			end

		elseif key == "Default" then
			ncb = function(parser, data)
				return callbacks.Default(p, data)
			end

		elseif key == "DefaultExpand" then
			ncb = function(parser, data)
				return callbacks.DefaultExpand(p, data)
			end

		elseif key == "EndCdataSection" then
			ncb = function(parser)
				return callbacks.EndCdataSection(p)
			end

		elseif key == "EndElement" then
			ncb = function(parser, elementName)
				local d = #stack
				context = stack[d-1]	-- revert to previous level context
				stack[d] = nil		-- delete last context
				return callbacks.EndElement(p, elementName)
			end

		elseif key == "EndNamespaceDecl" then
			ncb = function(parser, namespaceName)
				return callbacks.EndNamespaceDecl(p, namespaceName)
			end

		elseif key == "ExternalEntityRef" then  -- TODO: implement
			ncb = function(parser, subparser, base, systemId, publicId)
				-- subparser must be wrapped...
				-- do we need to pass current depth as its initial depth?
				return callbacks.ExternalEntityRef(p, subparser, base, systemId, publicId)
			end

		elseif key == "NotStandalone" then
			ncb = function(parser)
				return callbacks.NotStandalone(p)
			end

		elseif key == "NotationDecl" then  -- TODO: implement
			ncb = function(parser, notationName, base, systemId, publicId)
				return callbacks.NotationDecl(p, notationName, base, systemId, publicId)
			end

		elseif key == "ProcessingInstruction" then
			ncb = function(parser, target, data)
				if checks.PITarget and checks.PITarget < #target then
					return threat_error("processing instruction target too long")
				end

				if checks.PIData and checks.PIData < #data then
					return threat_error("processing instruction data too long")
				end

				context.children = context.children + 1
				if checks.maxChildren and context.children > checks.maxChildren then
					return threat_error("too many children")
				end

				context.charcount = nil -- reset text-length counter

				return callbacks.ProcessingInstruction(p, target, data)
			end

		elseif key == "StartCdataSection" then
			ncb = function(parser)
				return callbacks.StartCdataSection(p)
			end

		elseif key == "StartDoctypeDecl" then  -- TODO: implement
			ncb = function(parser, name, sysid, pubid, has_internal_subset)
				return callbacks.StartDoctypeDecl(p, name, sysid, pubid, has_internal_subset)
			end

		elseif key == "StartElement" then
			ncb = function(parser, elementName, attributes)
				context.children = context.children + 1
				if checks.maxChildren and context.children > checks.maxChildren then
					return threat_error("too many children")
				end

				context.charcount = nil -- reset text-length counter

				context = { children = 0 }
				local d = #stack
				if checks.depth and d > checks.depth then
					return threat_error("structure is too deep")
				end
				d = d + 1
				stack[d] = context

				if separator then
					-- handle namespaces
					local l
					local s,e = elementName:find(separator, 1, true)
					if s then
						l = #elementName - e  -- namespaced
					else
						l = #elementName -- not namespaced, entire key
					end
					if checks.localName and l > checks.localName then
						return threat_error("element localName too long")
					end
				else
					if checks.localName and #elementName > checks.localName then
						return threat_error("element name too long")
					end
				end

				local count = 0
				for key, value in pairs(attributes) do
					if type(key) == "string" then -- we only check the hash entries to prevent doubles
						count = count + 1
						if separator then
							-- handle namespaces
							local l
							local s,e = key:find(separator, 1, true)
							if s then
								l = #key - e  -- namespaced
							else
								l = #key -- not namespaced, entire key
							end
							if checks.localName and l > checks.localName then
								return threat_error("attribute localName too long")
							end
						else
							-- no namespaces
							if checks.localName and #key > checks.localName then
								return threat_error("attribute name too long")
							end
						end
						if checks.attribute and #value > checks.attribute then
							return threat_error("attribute value too long")
						end
					end
				end

				if checks.maxAttributes and count > checks.maxAttributes then
					return threat_error("too many attributes")
				end

				return callbacks.StartElement(p, elementName, attributes)
			end

		elseif key == "StartNamespaceDecl" then
			ncb = function(parser, namespaceName, namespaceUri)
				-- we're storing in the current context, which is one level up
				-- from the tag they are intended for. Because the namespace callbacks
				-- happen before the element callbacks. But for our purposes
				-- this is fine.
				context.ns = (context.ns or 0) + 1
				if checks.maxNamespaces and context.ns > checks.maxNamespaces then
					return threat_error("too many namespaces")
				end

				if checks.prefix and #(namespaceName or "") > checks.prefix then
					return threat_error("prefix too long")
				end

				if checks.namespaceUri and #namespaceUri > checks.namespaceUri then
					return threat_error("namespaceUri too long")
				end

				return callbacks.StartNamespaceDecl(p, namespaceName, namespaceUri)
			end

		-- elseif key == "UnparsedEntityDecl" then  -- TODO: implement?? superseeded by "EntityDecl"
		-- 	ncb = function(parser, entityName, base, systemId, publicId, notationName)
		-- 		return callbacks.UnparsedEntityDecl(p, entityName, base, systemId, publicId, notationName)
		-- 	end

		elseif key == "EndDoctypeDecl" then
			ncb = function(parser)
				return callbacks.EndDoctypeDecl(p)
			end

		elseif key == "XmlDecl" then
			ncb = function(parser, version, encoding, standalone)
				return callbacks.XmlDecl(p, version, encoding, standalone)
			end

		elseif key == "EntityDecl" then
			ncb = function(parser, entityName, is_parameter, value, base, systemId, publicId, notationName)
				if checks.entityName and checks.entityName < #entityName then
					return threat_error("entityName too long")
				end
				if checks.entity and value and checks.entity < #value then
					return threat_error("entity value too long")
				end
				if checks.entityProperty then
					if systemId and checks.entityProperty < #systemId then
						return threat_error("systemId too long")
					end
					if publicId and checks.entityProperty < #publicId then
						return threat_error("publicId too long")
					end
					if notationName and checks.entityProperty < #notationName then
						return threat_error("notationName too long")
					end
				end
				return callbacks.EntityDecl(p, entityName, is_parameter, value, base, systemId, publicId, notationName)
			end

		elseif key == "AttlistDecl" then
			ncb = function(parser, elementName, attrName, attrType, default, required)
				if separator then
					local ePrefix, eName, aPrefix, aName
					if checks.prefix or checks.localName then
						-- namespace based parsing, check against localName+prefix
						local colon = elementName:find(":", 1, true) or 0
						ePrefix = elementName:sub(1, colon-1)
						eName = elementName:sub(colon+1, -1)

						colon = attrName:find(":", 1, true) or 0
						aPrefix = attrName:sub(1, colon-1)
						aName = attrName:sub(colon+1, -1)

						if checks.localName then
							if checks.localName < #eName then
								return threat_error("element localName too long")
							end
							if checks.localName < #aName then
								return threat_error("attribute localName too long")
							end
						end
						if checks.prefix then
							if checks.prefix < #ePrefix then
								return threat_error("element prefix too long")
							end
							if checks.prefix < #aPrefix then
								return threat_error("attribute prefix too long")
							end
						end
					end
				else

					-- no namespace parsing, check against localName
					if checks.localName then
						if checks.localName < #elementName then
							return threat_error("elementName too long")
						end
						if checks.localName < #attrName then
							return threat_error("attributeName too long")
						end
					end
				end

				if default and checks.attribute then
					if checks.attribute < #default then
						return threat_error("attribute default too long")
					end
				end

				return callbacks.AttlistDecl(p, elementName, attrName, attrType, default, required)
			end

		elseif key == "ElementDecl" then
			ncb = function(parser, name, type, quantifier, children)
				if name or children then
					local checkName
					if separator then
						if checks.localName or checks.prefix then
							checkName = function(name)
								local colon = name:find(":", 1, true) or 0
								local ePrefix = name:sub(1, colon-1)
								local eName = name:sub(colon+1, -1)
								if checks.localName and checks.localName < #eName then
									return threat_error("elementDecl localName too long")
								end
								if checks.prefix and checks.prefix < #ePrefix then
									return threat_error("elementDecl prefix too long")
								end
								return true
							end
						end
					elseif checks.localName then
						checkName = function(name)
							if checks.localName < #name then
								return threat_error("elementDecl name too long")
							end
							return true
						end
					end

					if checkName then
						local function checkChild(child)
							if child.name and not checkName(child.name) then
								return
							end
							for _, subchild in ipairs(child.children or {}) do
								if not checkChild(subchild) then
									return
								end
							end
							return true
						end

						if not checkChild { name = name, children = children } then
							return
						end
					end
				end

				return callbacks.ElementDecl(p, name, type, quantifier, children)
			end

		elseif key == "SkippedEntity" then
			ncb = function(parser, name, isParameter)
				return callbacks.SkippedEntity(p, name, isParameter)
			end

		elseif key == "threat" then
			-- threat protection config table, remove, do not pass on
			ncb = nil

		else
			-- unknown entry, just copy
			ncb = cb
		end

		new_cbs[key] = ncb
	end

	-- create final parser with updated/wrapped callbacks
	local err
	parser, err = lxp.new(new_cbs, separator, merge_character_data)
	if not parser then
		return parser, err
	end

	return p
end


return threat
