diff --git a/lua/spec/components_spec.lua b/lua/spec/components_spec.lua index 0d46d6a2bd5..d220f73c6ad 100644 --- a/lua/spec/components_spec.lua +++ b/lua/spec/components_spec.lua @@ -284,3 +284,53 @@ describe('Components/Html', function() assert.are.equal('ab', result) end) end) + +describe('Components/Html attribute escaping', function() + it('escapes ampersand in attribute value', function() + local vNode = Html.Span{attributes = {title = 'a&b'}} + assert.is_true(tostring(vNode):find('title="a&b"', nil, true) ~= nil) + end) + + it('escapes double-quote in attribute value', function() + local vNode = Html.Span{attributes = {title = 'say "hello"'}} + assert.is_true(tostring(vNode):find('"', nil, true) ~= nil) + end) + + it('escapes less-than in attribute value', function() + local vNode = Html.Span{attributes = {title = '1<2'}} + assert.is_true(tostring(vNode):find('<', nil, true) ~= nil) + end) + + it('escapes greater-than in attribute value', function() + local vNode = Html.Span{attributes = {title = '2>1'}} + assert.is_true(tostring(vNode):find('>', nil, true) ~= nil) + end) + + it('escapes ampersand in class name', function() + local vNode = Html.Span{classes = {'a&b'}} + assert.is_true(tostring(vNode):find('class="a&b"', nil, true) ~= nil) + end) + + it('renders boolean true attribute without a value', function() + local vNode = Html.Span{attributes = {disabled = true}} + local result = tostring(vNode) + assert.is_true(result:find(' disabled', nil, true) ~= nil) + assert.is_false(result:find('disabled=', nil, true) ~= nil) + end) + + it('omits boolean false attribute entirely', function() + local vNode = Html.Span{attributes = {disabled = false}} + local result = tostring(vNode) + assert.is_false(result:find('disabled', nil, true) ~= nil) + end) + + it('escapes css value containing special characters', function() + -- CSS values with < or > should be escaped in the style attribute + local vNode = Html.Span{css = {content = '"<>"'}} + local result = tostring(vNode) + assert.is_true(result:find('style=', nil, true) ~= nil) + assert.is_false(result:find('"<>"', nil, true) ~= nil) + assert.is_true(result:find('<>', nil, true) ~= nil) + assert.is_true(result:find('"', nil, true) ~= nil) + end) +end) diff --git a/lua/wikis/commons/Widget/Renderer.lua b/lua/wikis/commons/Widget/Renderer.lua index e415ba7b506..2b5887a7e41 100644 --- a/lua/wikis/commons/Widget/Renderer.lua +++ b/lua/wikis/commons/Widget/Renderer.lua @@ -12,6 +12,102 @@ local Types = Lua.import('Module:Widget/Types') local Renderer = {} +-- List of HTML tags that cannot have children and do not need closing tags +local selfClosingTags = { + area = true, + base = true, + br = true, + col = true, + command = true, + embed = true, + hr = true, + img = true, + input = true, + keygen = true, + link = true, + meta = true, + param = true, + source = true, + track = true, + wbr = true, +} + +-- Basic attribute escaper (prevents quotes from breaking HTML) +local htmlencodeMap = { + ['>'] = '>', + ['<'] = '<', + ['&'] = '&', + ['"'] = '"', +} + +---@param str any +---@return string +local function escapeAttr(str) + if type(str) ~= 'string' then + str = tostring(str) + end + if not str:find('[><&"]') then return str end + return (str:gsub('[><&"]', htmlencodeMap)) +end + +--- Builds an HTML string from the given tag, props, and children +---@param tag string +---@param props {classes?: string[], css?: table, attributes?: table} +---@param renderedChildren string? +---@return string +local function buildHtmlString(tag, props, renderedChildren) + local buffer = { '<', tag } + + if props.classes then + table.insert(buffer, ' class="') + table.insert(buffer, escapeAttr(table.concat(props.classes, ' '))) + table.insert(buffer, '"') + end + + if props.css then + table.insert(buffer, ' style="') + for key, value in pairs(props.css) do + table.insert(buffer, key) + table.insert(buffer, ':') + table.insert(buffer, escapeAttr(value)) + table.insert(buffer, ';') + end + table.insert(buffer, '"') + end + + if props.attributes then + for key, value in pairs(props.attributes) do + if type(value) == 'boolean' then + -- Boolean attributes like `disabled` or `checked` + if value == true then + table.insert(buffer, ' ') + table.insert(buffer, key) + end + else + table.insert(buffer, ' ') + table.insert(buffer, key) + table.insert(buffer, '="') + table.insert(buffer, escapeAttr(value)) + table.insert(buffer, '"') + end + end + end + + if selfClosingTags[tag] then + table.insert(buffer, ' />') + else + table.insert(buffer, '>') + if renderedChildren and renderedChildren ~= '' then + table.insert(buffer, renderedChildren) + end + table.insert(buffer, '') + end + + return table.concat(buffer) +end + --- Renders a Virtual Node (VNode) into a string ---@param vNode Renderable|Renderable[]|nil ---@param context Context? @@ -80,29 +176,16 @@ function Renderer.render(vNode, context) -- Handle HTML Tags if type(renderFn) == 'string' then ---@cast vNode HtmlNode - local props = vNode.props - local tagName = renderFn - local tag - if tagName == 'fragment' then - tag = mw.html.create() - else - tag = mw.html.create(tagName) + local renderedChildren = '' + if vNode.props.children then + renderedChildren = Renderer.render(vNode.props.children, context) end - if props.classes then - tag:addClass(table.concat(props.classes, ' ')) - end - if props.css then tag:css(props.css) end - if props.attributes then tag:attr(props.attributes) end - - if props.children then - local childContent = Renderer.render(props.children, context) - if childContent ~= '' then - tag:node(childContent) - end + if renderFn == 'fragment' then + return renderedChildren end - return tostring(tag) + return buildHtmlString(renderFn, vNode.props, renderedChildren) end -- Handle Functional Components