Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Plugins: user plugins can declare a monochrome inline SVG icon (`icon.svg`, at most 8 KB, plain shapes only), drawn as a template image in the switcher and menu card; an SVG that fails validation falls back to the monogram (#4608). Thanks @shivanathd!
- Plugins: detail rows can declare normal, warning, or critical severity independently of progress, with system colors and warning symbols (#4602). Thanks @DamonYoungWork!

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ extension StatusItemController {
plugin.manifest.name,
plugin.manifest.icon.monogram,
plugin.manifest.icon.tint,
plugin.manifest.icon.svg ?? "",
].map { "\($0.utf8.count):\($0)" }.joined()
}.joined()
return [
Expand Down
59 changes: 54 additions & 5 deletions Sources/CodexBar/StatusItemController+UserPlugins.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ extension StatusItemController {
}

func userPluginSwitcherIcon(for plugin: UserProviderPlugin) -> NSImage {
if let image = Self.userPluginSVGImage(plugin) {
return image
}
let size = NSSize(width: 16, height: 16)
let image = NSImage(size: size, flipped: false) { rect in
let rawTint = plugin.manifest.icon.tint.dropFirst()
Expand All @@ -188,6 +191,47 @@ extension StatusItemController {
image.isTemplate = false
return image
}

/// The plugin's validated SVG as a template image, or nil when it has none or it does not decode.
static func userPluginSVGImage(_ plugin: UserProviderPlugin) -> NSImage? {
guard let svg = plugin.manifest.icon.svg,
let source = NSImage(data: Data(svg.utf8)),
source.size.width.isFinite, source.size.height.isFinite,
source.size.width > 0, source.size.height > 0,
source.size.width <= CGFloat(ProviderPluginIconSVG.maximumCanvasDimension),
source.size.height <= CGFloat(ProviderPluginIconSVG.maximumCanvasDimension),
let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: 32,
pixelsHigh: 32,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0),
let context = NSGraphicsContext(bitmapImageRep: bitmap)
else { return nil }
let scale = 32 / max(source.size.width, source.size.height)
guard scale.isFinite else { return nil }
source.size = NSSize(width: source.size.width * scale, height: source.size.height * scale)
let rect = NSRect(
x: (32 - source.size.width) / 2,
y: (32 - source.size.height) / 2,
width: source.size.width,
height: source.size.height)
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSGraphicsContext.current = context
context.cgContext.clear(NSRect(x: 0, y: 0, width: 32, height: 32))
source.draw(in: rect)
bitmap.size = NSSize(width: 16, height: 16)
let image = NSImage(size: bitmap.size)
image.addRepresentation(bitmap)
image.isTemplate = true
return image
}
}

struct UserPluginQuotaPresentation: Equatable {
Expand Down Expand Up @@ -226,11 +270,16 @@ private struct UserPluginMenuCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 9) {
HStack(spacing: 8) {
Text(self.plugin.manifest.icon.monogram)
.font(.caption.bold())
.foregroundStyle(.white)
.frame(width: 26, height: 26)
.background(self.tint, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
Group {
if let image = StatusItemController.userPluginSVGImage(self.plugin) {
Image(nsImage: image).resizable().scaledToFit().padding(4)
} else {
Text(self.plugin.manifest.icon.monogram).font(.caption.bold())
}
}
.foregroundStyle(.white)
.frame(width: 26, height: 26)
.background(self.tint, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
VStack(alignment: .leading, spacing: 1) {
Text(self.plugin.manifest.name).font(.headline)
Text(self.plugin.fileURL.lastPathComponent)
Expand Down
126 changes: 126 additions & 0 deletions Sources/CodexBarCore/Plugins/ProviderPluginIconSVG.swift
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow color keywords that contain url

When a plugin uses the standard SVG color keyword burlywood for fill or stroke, the paint regex accepts it but this substring check rejects it because the name contains url, so the otherwise valid SVG is discarded and the UI shows the monogram instead. The exact paint grammar already prevents url(...), so URL syntax should be rejected without banning substrings inside valid color names.

Useful? React with 👍 / 👎.

}
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject encoded DTD declarations during SVG validation

When an icon declares encoding="UTF-7", < can be written as +ADw-, so an external-DTD payload contains neither literal checked substring; Foundation's XMLParser then decodes it and ProviderPluginIconSVG.isValid returns true for inputs such as +ADw-!DOCTYPE svg SYSTEM .... Because userPluginSVGImage later hands the original bytes to NSImage, this defeats the promised DTD/entity boundary and exposes the native decoder to external-entity/network processing; reject non-UTF-8 declarations or reject DTD/entity declarations from the parser itself.

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()
}
}
}
9 changes: 7 additions & 2 deletions Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ public struct ProviderPluginSetting: Equatable, Sendable {
public struct ProviderPluginIcon: Equatable, Sendable {
public let monogram: String
public let tint: String
/// A validated monochrome SVG, rendered as a template image. Nil means the monogram is shown.
public let svg: String?

public init(monogram: String, tint: String) {
public init(monogram: String, tint: String, svg: String? = nil) {
self.monogram = monogram
self.tint = tint
self.svg = svg.flatMap { ProviderPluginIconSVG.isValid($0) ? $0 : nil }
}
}

Expand Down Expand Up @@ -420,7 +423,9 @@ public struct ProviderPluginManifest: Sendable {
else {
throw ProviderPluginError.invalidManifest("icon tint must be a #RRGGBB color")
}
return ProviderPluginIcon(monogram: monogram, tint: tint.uppercased())
// An SVG that fails validation is dropped rather than failing the plugin: the monogram is the fallback.
let svg = try Self.optionalString(value, property: "svg")
return ProviderPluginIcon(monogram: monogram, tint: tint.uppercased(), svg: svg)
}

private static func normalizedCookieDomain(_ rawValue: String) throws -> String {
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ interface CodexBarPluginContext {
interface CodexBarProviderDefinition {
id: string;
name: string;
icon?: { monogram?: string; tint?: string };
/** `svg`: optional monochrome SVG (≤ 8 KB, canvas ≤ 4096; see docs/plugins.md) shown as a template image; invalid SVG falls back to the monogram. */
icon?: { monogram?: string; tint?: string; svg?: string };
/** Defaults to true: a switcher tab when Merge Icons is on. False keeps an appended card. */
topLevel?: boolean;
endpoints: CodexBarEndpoint[];
Expand Down
104 changes: 104 additions & 0 deletions Tests/CodexBarTests/UserProviderPluginTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,110 @@ extension UserProviderPluginTests {
.write(to: directory.appendingPathComponent("\(stage)-menus.json"))
}

@MainActor
@Test
func `plugin svg icon is kept and rendered as a template image`() throws {
let fixture = try Fixture()
defer { fixture.remove() }
let svg = #"""
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 16 16">
<path d="M2 2h12v12H2z"/></svg>
"""#
_ = try fixture.write(name: "svg.js", source: Self.iconPlugin(id: "svg-meter", svg: svg))
let plugin = try #require(UserProviderPluginRegistry.refresh(
loader: fixture.loader(transport: RecordingTransport(responseJSON: "{}"))).first?.plugin)

#expect(plugin.manifest.icon.svg == svg)
let open = #"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">"#
for paint in ["none", "currentColor", "black", "#fff", "#336699cc", "rgb(1, 2, 3)", "rgba(0,0,0,50%)"] {
let shape = #"<path d="M2 2h12v12H2z" fill=""# + paint + #"" stroke=""# + paint
+ #"" transform="rotate(45 8 8)"/>"#
#expect(ProviderPluginIconSVG.isValid(open + shape + "</svg>"), "rejected: \(paint)")
}
#expect(plugin.manifest.icon.monogram == "SV")
let image = try #require(StatusItemController.userPluginSVGImage(plugin))
#expect(image.isTemplate)
#expect(image.size == NSSize(width: 16, height: 16))
let bitmap = try #require(image.representations.first as? NSBitmapImageRep)
#expect(bitmap.pixelsWide == 32)
#expect(bitmap.pixelsHigh == 32)
#expect(image.representations.count == 1)
let center = try #require(bitmap.colorAt(x: 16, y: 16))
#expect(center.alphaComponent > 0.9)
}

@Test
func `unsafe or oversized svg icons fall back to the monogram`() throws {
let open = #"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">"#
let rejected = [
#"<svg width="1000000000" height="16"><path d="M0 0h1v1z"/></svg>"#,
#"<svg viewBox="0 0 1e100 16"><path d="M0 0h1v1z"/></svg>"#,
#"<svg width="16" viewBox="0 0 16 1e-99"><path d="M0 0h1v1z"/></svg>"#,
open + "<script>alert(1)</script></svg>",
open + "<style>path{fill:red}</style></svg>",
open + "<foreignObject><div/></foreignObject></svg>",
open + #"<image href="https://example.com/x.png"/></svg>"#,
open + #"<path d="M0 0h1v1z" fill="url(#g)"/></svg>"#,
open + #"<path d="M0 0h1v1z" onload="x()"/></svg>"#,
// CSS escapes, comments and character references cannot smuggle a URL past the check.
open + #"<path d="M0 0h1v1z" fill="u\72l(https://example.com/p.svg#x)"/></svg>"#,
open + #"<path d="M0 0h1v1z" stroke="U\52L(#g)"/></svg>"#,
open + #"<path d="M0 0h1v1z" fill="&#117;rl(#g)"/></svg>"#,
open + #"<path d="M0 0h1v1z" fill="red;background:url(x)"/></svg>"#,
open + #"<path d="M0 0h1v1z" fill="/**/red"/></svg>"#,
#"<svg xmlns="https://example.com/other" viewBox="0 0 16 16"><path d="M0 0h1v1z"/></svg>"#,
// Red team: other ways to reference or inject.
open + #"<path d="M0 0h1v1z" fill="&#x55;RL(#g)"/></svg>"#,
open + #"<path d="M0 0h1v1z" fill="\#u{FF55}\#u{FF52}\#u{FF4C}(#g)"/></svg>"#,
open + #"<path d="M0 0h1v1z" fill="url (#g)"/></svg>"#,
open + #"<path d="M0 0h1v1z" fill="image-set(&quot;x.png&quot; 1x)"/></svg>"#,
open + #"<path d="M0 0h1v1z" style="fill:red"/></svg>"#,
open + ##"<use href="#a"/></svg>"##,
open + #"<a href="https://example.com"><path d="M0 0h1v1z"/></a></svg>"#,
open + #"<path xlink:href="https://example.com/x" d="M0 0h1v1z"/></svg>"#,
#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M0 0h1v1z"/></svg>"#,
#"<svg xmlns="http://www.w3.org/2000/svg" xml:base="https://example.com/"><path d="M0 0h1v1z"/></svg>"#,
open + #"<path xmlns="https://example.com/other" d="M0 0h1v1z"/></svg>"#,
#"<?xml-stylesheet href="https://example.com/s.css"?><svg xmlns="http://www.w3.org/2000/svg"/>"#,
open + #"<path d="M0 0h1v1z" fill="red&#59;x"/></svg>"#,
open + #"<path d="M0 0h1v1z" fill="image-set(x.png 1x)"/></svg>"#,
open + #"<path d="M0 0h1v1z" stroke="paint(x)"/></svg>"#,
open + #"<path d="M0 0h1v1z" fill="var(--x)"/></svg>"#,
open + "<path d=\"M0 0h1v1z\" fill=\"red\u{0}\"/></svg>",
#"<!DOCTYPE svg [<!ENTITY x "y">]><svg><path d="&x;"/></svg>"#,
#"<g><path d="M0 0h1v1z"/></g>"#,
open + #"<path d=""# + String(repeating: "M0 0h1v1z", count: 1000) + #""/></svg>"#,
"",
]
for svg in rejected {
#expect(!ProviderPluginIconSVG.isValid(svg), "accepted: \(svg.prefix(60))")
}

let fixture = try Fixture()
defer { fixture.remove() }
_ = try fixture.write(name: "bad.js", source: Self.iconPlugin(id: "bad-meter", svg: rejected[0]))
let plugin = try #require(UserProviderPluginRegistry.refresh(
loader: fixture.loader(transport: RecordingTransport(responseJSON: "{}"))).first?.plugin)
#expect(plugin.manifest.icon.svg == nil)
#expect(plugin.manifest.icon.monogram == "SV")
#expect(ProviderPluginIcon(monogram: "SV", tint: "#336699", svg: rejected[0]).svg == nil)
}

private static func iconPlugin(id: String, svg: String) -> String {
// Test SVGs contain no backticks or `${`, so a JS template literal carries them verbatim.
let literal = "`" + svg + "`"
return """
defineProvider({
id: "\(id)",
name: "SVG Meter",
icon: { monogram: "SV", tint: "#0176D3", svg: \(literal) },
endpoints: ["https://\(id).example"],
settings: [],
fetchUsage() { return { primary: { usedPercent: 12 } }; },
});
"""
}

private static func menuPlugin(id: String, name: String, topLevel: Bool? = nil) -> String {
"""
defineProvider({
Expand Down
Loading