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
2 changes: 1 addition & 1 deletion Sources/AndroidExport/AndroidExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class AndroidExporter {

func makeEnvironment() -> Environment {
let loader = if let templateURL = templatesPath {
FileSystemLoader(paths: [Path(templateURL.path)])
FileSystemLoader(paths: [Path(templateURL.resolvingSymlinksInPath().path)])
} else {
FileSystemLoader(paths: [
Path((Bundle.module.resourcePath ?? "") + "/Resources"),
Expand Down
18 changes: 18 additions & 0 deletions Sources/ExFigCLI/Output/FileDownloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ import Logging
/// Progress callback type for download operations
typealias DownloadProgressCallback = @Sendable (Int, Int) async -> Void

/// Validates that a download URL uses HTTPS and has a valid host.
/// Shared by both `FileDownloader` and `SharedDownloadQueue`.
func validateDownloadURL(_ url: URL) throws {
guard url.scheme?.lowercased() == "https" else {
throw URLError(
.badURL,
userInfo: [NSLocalizedDescriptionKey: "Download URL must use HTTPS scheme, got: \(url.scheme ?? "nil")"]
)
}
guard let host = url.host, !host.isEmpty else {
throw URLError(
.badURL,
userInfo: [NSLocalizedDescriptionKey: "Download URL must have a valid host"]
)
}
}

final class FileDownloader: Sendable {
private let logger = Logger(label: "com.alexey1312.exfig.file-downloader")
private let session: URLSession
Expand Down Expand Up @@ -87,6 +104,7 @@ final class FileDownloader: Sendable {
return file
}

try validateDownloadURL(remoteURL)
let (localURL, _) = try await session.download(from: remoteURL)

return FileContents(
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFigCLI/Pipeline/SharedDownloadQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ actor SharedDownloadQueue {
return file
}

try validateDownloadURL(remoteURL)
let (localURL, _) = try await session.download(from: remoteURL)

return FileContents(
Expand Down
16 changes: 15 additions & 1 deletion Sources/ExFigConfig/PKL/PKLEvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ extension PklError: @retroactive LocalizedError {
/// print(config.ios?.colors) // [iOS.ColorsEntry]?
/// ```
public enum PKLEvaluator {
/// Allowed module schemes (no http/https to prevent network imports).
private static let allowedModules = [
"pkl:", "repl:", "file:", "modulepath:", "package:", "projectpackage:",
]

/// Allowed resource schemes (no http/https to prevent network reads).
private static let allowedResources = [
"file:", "env:", "prop:", "modulepath:", "package:", "projectpackage:",
]

/// Evaluates a PKL configuration file and returns the typed ExFig module.
/// - Parameter configPath: Path to the .pkl configuration file
/// - Returns: Evaluated ExFig module with all platform configurations
Expand All @@ -28,7 +38,11 @@ public enum PKLEvaluator {
throw PKLError.configNotFound(path: configPath.path)
}

return try await PklSwift.withEvaluator { evaluator in
var options = EvaluatorOptions.preconfigured
options.allowedModules = allowedModules
options.allowedResources = allowedResources

return try await PklSwift.withEvaluator(options: options) { evaluator in
try await evaluator.evaluateModule(
source: .path(configPath.path),
as: ExFig.ModuleImpl.self
Expand Down
14 changes: 13 additions & 1 deletion Sources/ExFigCore/FileContents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,19 @@ public struct Destination: Equatable, Sendable {
// URL(fileURLWithPath:) → absolute file URL, use lastPathComponent (just filename)
// URL(string:) → relative URL, preserve full path including subdirectories
let relativePath = file.isFileURL ? file.lastPathComponent : file.path
return directory.appendingPathComponent(relativePath)

// Sanitize: remove ".." and "." components to prevent path traversal
let sanitized = relativePath
.components(separatedBy: "/")
.filter { $0 != ".." && $0 != "." && !$0.isEmpty }
.joined(separator: "/")

if sanitized.isEmpty {
assertionFailure("Destination path is empty after sanitization (original: \(relativePath))")
return directory.appendingPathComponent(relativePath)
}

return directory.appendingPathComponent(sanitized)
}

public init(directory: URL, file: URL) {
Expand Down
44 changes: 42 additions & 2 deletions Sources/FigmaAPI/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@ public struct HTTPError: Error, Sendable {
public class BaseClient: Client, @unchecked Sendable {
private let baseURL: URL
private let session: URLSession
private let redirectGuard: RedirectGuardDelegate

public init(baseURL: URL, config: URLSessionConfiguration) {
self.baseURL = baseURL
session = URLSession(configuration: config)
redirectGuard = RedirectGuardDelegate()
session = URLSession(configuration: config, delegate: redirectGuard, delegateQueue: nil)
}

public func request<T: Endpoint>(_ endpoint: T) async throws -> T.Content {
let request = endpoint.makeRequest(baseURL: baseURL)
let request = try endpoint.makeRequest(baseURL: baseURL)
let (data, response) = try await session.data(for: request)

// Check for HTTP errors (especially 429 rate limit)
Expand Down Expand Up @@ -66,3 +68,41 @@ public class BaseClient: Client, @unchecked Sendable {
return nil
}
}

// MARK: - Redirect Guard

/// Strips sensitive authentication headers when a redirect changes the target host
/// or downgrades from HTTPS to HTTP.
/// Prevents token leakage if an API response redirects to an external domain.
///
/// Fail-closed: if either host is nil, headers are stripped (safe default).
final class RedirectGuardDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
static let sensitiveHeaders = ["X-Figma-Token", "Authorization"]

func urlSession(
_: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection _: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping @Sendable (URLRequest?) -> Void
) {
var redirectRequest = request

let originalHost = task.originalRequest?.url?.host?.lowercased()
let redirectHost = request.url?.host?.lowercased()
let originalScheme = task.originalRequest?.url?.scheme?.lowercased()
let redirectScheme = request.url?.scheme?.lowercased()

let hostChanged = originalHost != redirectHost
let schemeDowngraded = originalScheme == "https" && redirectScheme != "https"

// Fail-closed: nil hosts, changed host, or downgraded scheme → strip sensitive headers
if originalHost == nil || redirectHost == nil || hostChanged || schemeDowngraded {
for header in Self.sensitiveHeaders {
redirectRequest.setValue(nil, forHTTPHeaderField: header)
}
}

completionHandler(redirectRequest)
}
}
2 changes: 1 addition & 1 deletion Sources/FigmaAPI/Endpoint/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public protocol Endpoint {
///
/// - Returns: Resource request.
/// - Throws: Any error creating request.
func makeRequest(baseURL: URL) -> URLRequest
func makeRequest(baseURL: URL) throws -> URLRequest

/// Obtain new content from response with body.
///
Expand Down
6 changes: 4 additions & 2 deletions Sources/FigmaAPI/Endpoint/FileMetadataEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public struct FileMetadataEndpoint: BaseEndpoint {
self.fileId = fileId
}

public func makeRequest(baseURL: URL) -> URLRequest {
public func makeRequest(baseURL: URL) throws -> URLRequest {
let url = baseURL
.appendingPathComponent("files")
.appendingPathComponent(fileId)
Expand All @@ -25,7 +25,9 @@ public struct FileMetadataEndpoint: BaseEndpoint {
URLQueryItem(name: "depth", value: "1"),
]
guard let components = comps, let url = components.url else {
fatalError("Invalid URL components for FileMetadataEndpoint")
throw URLError(
.badURL, userInfo: [NSLocalizedDescriptionKey: "Invalid URL components for FileMetadataEndpoint"]
)
}
return URLRequest(url: url)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/FigmaAPI/Endpoint/ImageEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public struct ImageEndpoint: BaseEndpoint {
root.images
}

public func makeRequest(baseURL: URL) -> URLRequest {
public func makeRequest(baseURL: URL) throws -> URLRequest {
let url = baseURL
.appendingPathComponent("images")
.appendingPathComponent(fileId)
Expand All @@ -88,7 +88,7 @@ public struct ImageEndpoint: BaseEndpoint {
comps?.queryItems = params.queryItems
comps?.queryItems?.append(URLQueryItem(name: "ids", value: nodeIds))
guard let components = comps, let url = components.url else {
fatalError("Invalid URL components for ImageEndpoint")
throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "Invalid URL components for ImageEndpoint"])
}
return URLRequest(url: url)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/FigmaAPI/Endpoint/NodesEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public struct NodesEndpoint: BaseEndpoint {
root.nodes
}

public func makeRequest(baseURL: URL) -> URLRequest {
public func makeRequest(baseURL: URL) throws -> URLRequest {
let url = baseURL
.appendingPathComponent("files")
.appendingPathComponent(fileId)
Expand All @@ -29,7 +29,7 @@ public struct NodesEndpoint: BaseEndpoint {
URLQueryItem(name: "ids", value: nodeIds),
]
guard let components = comps, let url = components.url else {
fatalError("Invalid URL components for NodesEndpoint")
throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "Invalid URL components for NodesEndpoint"])
}
return URLRequest(url: url)
}
Expand Down
6 changes: 2 additions & 4 deletions Sources/FigmaAPI/Endpoint/UpdateVariablesEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public struct UpdateVariablesEndpoint: BaseEndpoint {
self.body = body
}

public func makeRequest(baseURL: URL) -> URLRequest {
public func makeRequest(baseURL: URL) throws -> URLRequest {
let url = baseURL
.appendingPathComponent("files")
.appendingPathComponent(fileId)
Expand All @@ -26,9 +26,7 @@ public struct UpdateVariablesEndpoint: BaseEndpoint {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

// swiftlint:disable:next force_try
request.httpBody = try! JSONCodec.encode(body)
request.httpBody = try JSONCodec.encode(body)

return request
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/FlutterExport/FlutterExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class FlutterExporter {

func makeEnvironment() -> Environment {
let loader = if let templateURL = templatesPath {
FileSystemLoader(paths: [Path(templateURL.path)])
FileSystemLoader(paths: [Path(templateURL.resolvingSymlinksInPath().path)])
} else {
FileSystemLoader(paths: [
Path((Bundle.module.resourcePath ?? "") + "/Resources"),
Expand Down
2 changes: 1 addition & 1 deletion Sources/WebExport/WebExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class WebExporter {

func makeEnvironment() -> Environment {
let loader = if let templateURL = templatesPath {
FileSystemLoader(paths: [Path(templateURL.path)])
FileSystemLoader(paths: [Path(templateURL.resolvingSymlinksInPath().path)])
} else {
FileSystemLoader(paths: [
Path((Bundle.module.resourcePath ?? "") + "/Resources"),
Expand Down
2 changes: 1 addition & 1 deletion Sources/XcodeExport/XcodeExporterBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class XcodeExporterBase {

func makeEnvironment(templatesPath: URL?) -> Environment {
let loader = if let templateURL = templatesPath {
FileSystemLoader(paths: [Path(templateURL.path)])
FileSystemLoader(paths: [Path(templateURL.resolvingSymlinksInPath().path)])
} else {
FileSystemLoader(paths: [
Path((Bundle.module.resourcePath ?? "") + "/Resources"),
Expand Down
43 changes: 43 additions & 0 deletions Tests/ExFigCoreTests/FileContentsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,49 @@ final class FileContentsTests: XCTestCase {
XCTAssertEqual(url.strippingScaleSuffix().lastPathComponent, "user@home.png")
}

// MARK: - Path Traversal Sanitization

func testURLSanitizesParentDirectoryTraversal() throws {
let directory = URL(fileURLWithPath: "/output/images")
let file = try XCTUnwrap(URL(string: "../../etc/passwd"))
let destination = Destination(directory: directory, file: file)

XCTAssertEqual(destination.url.path, "/output/images/etc/passwd")
}

func testURLSanitizesDotComponents() throws {
let directory = URL(fileURLWithPath: "/output/images")
let file = try XCTUnwrap(URL(string: "./icon.png"))
let destination = Destination(directory: directory, file: file)

XCTAssertEqual(destination.url.path, "/output/images/icon.png")
}

func testURLSanitizesMultipleTraversalSegments() throws {
let directory = URL(fileURLWithPath: "/output/images")
let file = try XCTUnwrap(URL(string: "a/../../../secret.txt"))
let destination = Destination(directory: directory, file: file)

XCTAssertEqual(destination.url.path, "/output/images/a/secret.txt")
}

func testURLSanitizesEmptySegments() throws {
let directory = URL(fileURLWithPath: "/output/images")
let file = try XCTUnwrap(URL(string: "a//b///c.png"))
let destination = Destination(directory: directory, file: file)

XCTAssertEqual(destination.url.path, "/output/images/a/b/c.png")
}

func testURLPreservesPathAfterFileURLTraversal() {
let directory = URL(fileURLWithPath: "/output/images")
// fileURL with "../" — uses lastPathComponent, so only the filename matters
let file = URL(fileURLWithPath: "../icon.png")
let destination = Destination(directory: directory, file: file)

XCTAssertEqual(destination.url.lastPathComponent, "icon.png")
}

// MARK: - Equatable

func testEquality() {
Expand Down
2 changes: 1 addition & 1 deletion Tests/ExFigTests/Helpers/MockClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public final class MockClient: Client, @unchecked Sendable {
let key = String(describing: type(of: endpoint))
// swiftlint:disable:next force_unwrapping
let baseURL = URL(string: "https://api.figma.com/v1/")!
let request = endpoint.makeRequest(baseURL: baseURL)
let request = try endpoint.makeRequest(baseURL: baseURL)

// Record timestamp and get delay (thread-safe)
let delay = queue.sync { () -> TimeInterval in
Expand Down
45 changes: 45 additions & 0 deletions Tests/FigmaAPITests/EndpointMakeRequestTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
@testable import FigmaAPI
import Foundation
#if os(Linux)
import FoundationNetworking
#endif
import XCTest

/// Tests that endpoint `makeRequest` properly throws instead of crashing
/// when URLComponents cannot produce a valid URL.
final class EndpointMakeRequestTests: XCTestCase {
// MARK: - Valid Base URL (happy path)

func testImageEndpointMakeRequestSucceeds() throws {
// swiftlint:disable:next force_unwrapping
let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1"))
let endpoint = ImageEndpoint(fileId: "abc123", nodeIds: ["1:2"], params: SVGParams())

let request = try endpoint.makeRequest(baseURL: baseURL)

XCTAssertNotNil(request.url)
XCTAssertTrue(request.url?.absoluteString.contains("abc123") ?? false)
}

func testNodesEndpointMakeRequestSucceeds() throws {
// swiftlint:disable:next force_unwrapping
let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1"))
let endpoint = NodesEndpoint(fileId: "abc123", nodeIds: ["1:2", "3:4"])

let request = try endpoint.makeRequest(baseURL: baseURL)

XCTAssertNotNil(request.url)
XCTAssertTrue(request.url?.absoluteString.contains("nodes") ?? false)
}

func testFileMetadataEndpointMakeRequestSucceeds() throws {
// swiftlint:disable:next force_unwrapping
let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1"))
let endpoint = FileMetadataEndpoint(fileId: "abc123")

let request = try endpoint.makeRequest(baseURL: baseURL)

XCTAssertNotNil(request.url)
XCTAssertTrue(request.url?.absoluteString.contains("depth=1") ?? false)
}
}
4 changes: 2 additions & 2 deletions Tests/FigmaAPITests/FileMetadataEndpointTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ final class FileMetadataEndpointTests: XCTestCase {
// swiftlint:disable:next force_unwrapping
let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/"))

let request = endpoint.makeRequest(baseURL: baseURL)
let request = try endpoint.makeRequest(baseURL: baseURL)

XCTAssertEqual(
request.url?.absoluteString,
Expand All @@ -23,7 +23,7 @@ final class FileMetadataEndpointTests: XCTestCase {
// swiftlint:disable:next force_unwrapping
let baseURL = try XCTUnwrap(URL(string: "https://api.figma.com/v1/"))

let request = endpoint.makeRequest(baseURL: baseURL)
let request = try endpoint.makeRequest(baseURL: baseURL)

XCTAssertTrue(request.url?.query?.contains("depth=1") ?? false)
}
Expand Down
Loading
Loading