Repository navigation
Allow user plugins to declare a monochrome SVG icon #4608
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fc94052
2732769
4fe7af9
06b3190
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import Foundation | ||
| #if canImport(FoundationXML) | ||
| import FoundationXML | ||
| #endif | ||
|
|
||
| /// Validates a plugin's monochrome SVG icon. Only plain vector shapes pass: no scripts, styles, links, | ||
| /// raster images, foreign objects, entities, or event handlers. Anything else is rejected so the host | ||
| /// falls back to the monogram. | ||
| public enum ProviderPluginIconSVG { | ||
| public static let maximumBytes = 8 * 1024 | ||
| public static let maximumCanvasDimension = 4096.0 | ||
|
|
||
| static let allowedElements: Set<String> = [ | ||
| "svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "title", "desc", | ||
| ] | ||
|
|
||
| static let allowedAttributes: Set<String> = [ | ||
| "xmlns", "version", "id", "viewBox", "width", "height", "fill", "fill-rule", "clip-rule", "opacity", | ||
| "fill-opacity", "transform", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2", | ||
| "points", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", | ||
| ] | ||
|
|
||
| static let svgNamespace = "http://www.w3.org/2000/svg" | ||
|
|
||
| /// Geometry, colours and transforms need only these characters. Excluding `\`, `/`, `:`, `;`, quotes and | ||
| /// `*` leaves no CSS escape, comment, declaration or URL syntax, so a value cannot reference anything. | ||
| static let allowedValueCharacters = CharacterSet( | ||
| charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,#%+-()\t\n\r") | ||
|
|
||
| /// Paint is the only place a function could load an image (`image-set()`, `paint()`, …), so it gets an exact | ||
| /// grammar: a keyword or colour name, a hex colour, or numeric `rgb()`/`rgba()`. | ||
| static let paintPattern = #"^(?:[A-Za-z]+|#[0-9A-Fa-f]{3,8}|rgba?\([0-9.%,\s]+\))$"# | ||
|
|
||
| static func isAllowedValue(_ name: String, _ value: String) -> Bool { | ||
| if name == "xmlns" { return value == self.svgNamespace } | ||
| if name == "fill" || name == "stroke" { | ||
| let paint = value.trimmingCharacters(in: .whitespaces) | ||
| return paint.range(of: self.paintPattern, options: .regularExpression) != nil | ||
| && paint.range(of: "url", options: .caseInsensitive) == nil | ||
| } | ||
| return value.unicodeScalars.allSatisfy { self.allowedValueCharacters.contains($0) } | ||
| && value.range(of: "url", options: .caseInsensitive) == nil | ||
| } | ||
|
|
||
| private static func hasBoundedCanvas(_ attributes: [String: String]) -> Bool { | ||
| // Paired dimensions prevent one length plus an extreme aspect ratio from implying a huge canvas. | ||
| guard (attributes["width"] == nil) == (attributes["height"] == nil) else { return false } | ||
| for key in ["width", "height"] { | ||
| guard let value = attributes[key] else { continue } | ||
| let number = value.lowercased().hasSuffix("px") ? String(value.dropLast(2)) : value | ||
| guard let length = Double(number.trimmingCharacters(in: .whitespacesAndNewlines)), | ||
| length.isFinite, length > 0, length <= self.maximumCanvasDimension | ||
| else { return false } | ||
| } | ||
| guard let viewBox = attributes["viewBox"] else { return attributes["width"] != nil } | ||
| let fields = viewBox.split { $0.isWhitespace || $0 == "," } | ||
| let numbers = fields.compactMap { Double($0) } | ||
| return fields.count == 4 && numbers.count == 4 && numbers.allSatisfy(\.isFinite) | ||
| && numbers[2] > 0 && numbers[2] <= self.maximumCanvasDimension | ||
| && numbers[3] > 0 && numbers[3] <= self.maximumCanvasDimension | ||
| } | ||
|
|
||
| public static func isValid(_ svg: String) -> Bool { | ||
| guard !svg.isEmpty, svg.utf8.count <= self.maximumBytes, | ||
| svg.range(of: "<!DOCTYPE", options: .caseInsensitive) == nil, | ||
| svg.range(of: "<!ENTITY", options: .caseInsensitive) == nil, | ||
|
Comment on lines
+65
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an icon declares Useful? React with 👍 / 👎. |
||
| let data = svg.data(using: .utf8) | ||
| else { return false } | ||
| let delegate = AllowlistDelegate() | ||
| let parser = XMLParser(data: data) | ||
| parser.shouldResolveExternalEntities = false | ||
| parser.delegate = delegate | ||
| return parser.parse() && delegate.isValid && delegate.sawRoot | ||
| } | ||
|
|
||
| private final class AllowlistDelegate: NSObject, XMLParserDelegate { | ||
| var isValid = true | ||
| var sawRoot = false | ||
| private var depth = 0 | ||
|
|
||
| func parser( | ||
| _ parser: XMLParser, | ||
| didStartElement elementName: String, | ||
| namespaceURI: String?, | ||
| qualifiedName qName: String?, | ||
| attributes attributeDict: [String: String] = [:]) | ||
| { | ||
| if self.depth == 0 { | ||
| if elementName == "svg", !self.sawRoot { self.sawRoot = true } else { self.reject(parser) } | ||
| } | ||
| self.depth += 1 | ||
| guard ProviderPluginIconSVG.allowedElements.contains(elementName), | ||
| elementName != "svg" || ProviderPluginIconSVG.hasBoundedCanvas(attributeDict), | ||
| attributeDict.allSatisfy({ name, value in | ||
| ProviderPluginIconSVG.allowedAttributes.contains(name) | ||
| && ProviderPluginIconSVG.isAllowedValue(name, value) | ||
| }) | ||
| else { | ||
| self.reject(parser) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| func parser( | ||
| _ parser: XMLParser, | ||
| didEndElement elementName: String, | ||
| namespaceURI: String?, | ||
| qualifiedName qName: String?) | ||
| { | ||
| self.depth -= 1 | ||
| } | ||
|
|
||
| func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { | ||
| self.reject(parser) | ||
| } | ||
|
|
||
| func parser(_ parser: XMLParser, foundProcessingInstructionWithTarget target: String, data: String?) { | ||
| self.reject(parser) | ||
| } | ||
|
|
||
| private func reject(_ parser: XMLParser) { | ||
| self.isValid = false | ||
| parser.abortParsing() | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
urlWhen a plugin uses the standard SVG color keyword
burlywoodforfillorstroke, the paint regex accepts it but this substring check rejects it because the name containsurl, so the otherwise valid SVG is discarded and the UI shows the monogram instead. The exact paint grammar already preventsurl(...), so URL syntax should be rejected without banning substrings inside valid color names.Useful? React with 👍 / 👎.