diff --git a/Sources/RequestDL/Documentation.docc/Essentials/Executing the Request/cURL Support/CURL-support.md b/Sources/RequestDL/Documentation.docc/Essentials/Executing the Request/cURL Support/CURL-support.md index 4b51ae05..f9890c55 100644 --- a/Sources/RequestDL/Documentation.docc/Essentials/Executing the Request/cURL Support/CURL-support.md +++ b/Sources/RequestDL/Documentation.docc/Essentials/Executing the Request/cURL Support/CURL-support.md @@ -22,7 +22,30 @@ let result = try await CURLTask(""" `CURLTask` parses the command straight into a ``RequestDL/RequestConfiguration`` — there's no `@PropertyBuilder` tree to declare from a string only known at runtime — but resolution and execution still go through the same pipeline ``RequestDL/DataTask`` uses, so caching, tracing, and executor requirements all apply exactly the same way. -> Important: Run only curl commands you wrote or trust. A command is a program for the request engine, not just data: `-F name=@path` uploads the local file it names, `--cert`, `--key` and `--cacert` read files from disk, `-k` / `--insecure` turns certificate verification off, and `-x` / `--proxy` and `--resolve` change where the request goes. A command assembled from user input, a pasted snippet or a network response can leak local files to a host of its choosing or remove TLS protection. Reject or inspect such a command first, or declare the request with ``RequestDL/DataTask`` instead. +> Important: Run only curl commands you wrote or trust. A command is a program for the request engine, not just data: `-F name=@path` uploads the local file it names, `--cert`, `--key` and `--cacert` read files from disk, `-k` / `--insecure` turns certificate verification off, and `-x` / `--proxy` and `--resolve` change where the request goes. A command assembled from user input, a pasted snippet or a network response can leak local files to a host of its choosing or remove TLS protection. Say what the command may do, as below, reject or inspect it first, or declare the request with ``RequestDL/DataTask`` instead. + +### A command you do not control + +``RequestDL/CURLTask/init(_:allowing:)`` takes the capabilities a command may use. A flag that needs one it was not given throws ``RequestDL/CURLParsingError`` with `unsupportedFlag` while the command is parsed, before any file is opened and before anything is sent: + +```swift +// Nothing it could read, redirect or weaken. +try await CURLTask(pasted, allowing: []) + .result() + +// The same, but it may name a proxy. +try await CURLTask(pasted, allowing: .proxy) + .result() +``` + +| Capability | Flags it covers | +|---|---| +| `.localFiles` | `-F name=@path` (relative paths too, which resolve against the working directory), `--cacert`, `--cert` / `-E`, `--key` | +| `.disabledTLSVerification` | `-k` / `--insecure` | +| `.proxy` | `-x` / `--proxy` | +| `.dnsOverrides` | `--resolve` | + +Everything else in the subset (the method, headers, a body, a form of plain fields, `-u`, redirects, `--compressed`, and the flags that only shape curl's own output) needs none. `CURLTask(_:)` allows all of them, as it always did. ### From a request to curl diff --git a/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/CURLTask.swift b/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/CURLTask.swift index 703fb022..e733f0d2 100644 --- a/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/CURLTask.swift +++ b/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/CURLTask.swift @@ -43,13 +43,21 @@ import struct Foundation.Data /// .Configuration.dnsOverride`) is incompatible with the `.urlSession` executor, so a command /// using it will only run under `.nio`/`.nioTransportServices`. /// -/// - Important: Pass only commands you wrote or trust. A command is a program for the request -/// engine, not just data. `-F name=@path` reads that local file and uploads it. `--cert`/`-E`/ -/// `--key` and `--cacert` read the files they name. `-k`/`--insecure` turns certificate -/// verification off, and `-x`/`--proxy` and `--resolve` redirect where the request goes. A -/// command built from user input, a pasted snippet or a network response can therefore leak -/// local files to a host of its choosing or strip TLS protection. Reject or inspect such a -/// command before handing it to `CURLTask`, or build the request with ``DataTask`` instead. +/// - Important: Pass only commands you wrote or trust, or say what the command may do. A +/// command is a program for the request engine, not just data. `-F name=@path` reads that local +/// file and uploads it. `--cert`/`-E`/`--key` and `--cacert` read the files they name. +/// `-k`/`--insecure` turns certificate verification off, and `-x`/`--proxy` and `--resolve` +/// redirect where the request goes. A command built from user input, a pasted snippet or a +/// network response can therefore leak local files to a host of its choosing or strip TLS +/// protection. ``init(_:allowing:)`` refuses those flags unless the capability that covers them +/// is given, so a command from a source you do not control can run with none of them: +/// +/// ```swift +/// try await CURLTask(pasted, allowing: []) +/// .result() +/// ``` +/// +/// `CURLTask(_:)` allows all of them, as it always did. /// /// The command is parsed straight into a ``RequestConfiguration`` rather than through /// `@PropertyBuilder`, since there is nothing to declare a `Property` tree from until the @@ -57,20 +65,86 @@ import struct Foundation.Data /// ``DataTask`` uses, so caching, tracing, and executor requirements all apply the same way. public struct CURLTask: RequestTask { + // MARK: - Public types + + /// What a curl command may do beyond describing a request: the flags that touch the machine + /// or change where, and how safely, the request goes. ``CURLTask/init(_:)`` allows all of + /// them; ``CURLTask/init(_:allowing:)`` refuses the flag of any that is not given, with + /// ``CURLParsingError/Context/unsupportedFlag``, before a file is opened or a request is + /// built. + public struct Capabilities: OptionSet, Sendable, Hashable { + + /// Reading a file from this machine: `-F name=@path` (any path, relative ones included, + /// which resolve against the working directory), `--cacert`, `--cert`/`-E` and `--key`. + public static let localFiles = Capabilities(rawValue: 1 << 0) + + /// Turning certificate verification off: `-k`/`--insecure`. + public static let disabledTLSVerification = Capabilities(rawValue: 1 << 1) + + /// Sending the request through a proxy: `-x`/`--proxy`. + public static let proxy = Capabilities(rawValue: 1 << 2) + + /// Sending the request to an address other than the one DNS gives for the host: + /// `--resolve`. + public static let dnsOverrides = Capabilities(rawValue: 1 << 3) + + /// Every capability, which is what ``CURLTask/init(_:)`` allows. + public static let all: Capabilities = [ + .localFiles, .disabledTLSVerification, .proxy, .dnsOverrides, + ] + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + } + // MARK: - Private properties private let command: String + private let capabilities: Capabilities // MARK: - Inits /// /// Initializes with a curl command line. /// + /// The command may use every flag the subset covers, local files, a proxy and + /// `-k`/`--insecure` included. Pass only commands you wrote or trust, or use + /// ``init(_:allowing:)``. + /// /// - Parameter command: The curl command line to parse. A leading literal `curl` token, if /// present, is ignored. /// public init(_ command: String) { + self.init(command, allowing: .all) + } + + /// + /// Initializes with a curl command line that may only use the given capabilities. + /// + /// A flag that needs a capability that is not in `capabilities` throws + /// ``CURLParsingError`` with ``CURLParsingError/Context/unsupportedFlag`` and the flag as + /// the token, while the command is parsed: before any file is opened and before anything is + /// sent. + /// + /// ```swift + /// // A command from a source you do not control, with nothing it could leak or redirect. + /// CURLTask(pasted, allowing: []) + /// + /// // The same, but it may name a proxy. + /// CURLTask(pasted, allowing: .proxy) + /// ``` + /// + /// - Parameters: + /// - command: The curl command line to parse. A leading literal `curl` token, if present, + /// is ignored. + /// - capabilities: What the command may do. An empty set refuses all of it. + /// + public init(_ command: String, allowing capabilities: Capabilities) { self.command = command + self.capabilities = capabilities } // MARK: - Public methods @@ -83,11 +157,11 @@ public struct CURLTask: RequestTask { /// `.description(_:onDescribe:)`, `.digestAuthentication()`, `.environment(...)`) was then a /// silent no-op. /// - /// - Throws: ``CURLParsingError`` if the command doesn't parse, or an error from the request - /// itself. + /// - Throws: ``CURLParsingError`` if the command doesn't parse or uses a flag its capabilities + /// do not cover, or an error from the request itself. @_spi(Private) public func _result(environment: RequestEnvironmentValues) async throws -> TaskResult { - let parsed = try await CURLCommandParser.parseCommand(command) + let parsed = try await CURLCommandParser.parseCommand(command, allowing: capabilities) return try await RawTask( content: RawRequestConfigurationProperty( diff --git a/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/Models/CURLCommandParser.swift b/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/Models/CURLCommandParser.swift index 50a0b43d..c94d4192 100644 --- a/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/Models/CURLCommandParser.swift +++ b/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/Models/CURLCommandParser.swift @@ -42,7 +42,13 @@ enum CURLCommandParser { try await parseCommand(command).requestConfiguration } - static func parseCommand(_ command: String) async throws -> Command { + /// - Parameter capabilities: What the command may do. A flag that needs one it does not have + /// throws ``CURLParsingError/Context/unsupportedFlag`` as soon as it is read: this is the + /// loop over the tokens, so nothing was opened or built by then. + static func parseCommand( + _ command: String, + allowing capabilities: CURLTask.Capabilities = .all + ) async throws -> Command { var tokens = try CURLTokenizer.tokenize(command) if tokens.first == "curl" { @@ -69,6 +75,12 @@ enum CURLCommandParser { var index = tokens.startIndex + func require(_ capability: CURLTask.Capabilities, for flag: String) throws { + guard capabilities.contains(capability) else { + throw CURLParsingError(.unsupportedFlag, token: flag) + } + } + func value(for flag: String) throws -> String { index += 1 @@ -100,7 +112,13 @@ enum CURLCommandParser { dataFragments.append(try value(for: token)) case "-F", "--form": - formItems.append(try makeFormItem(try value(for: token))) + let field = try value(for: token) + + if isFile(field) { + try require(.localFiles, for: token) + } + + formItems.append(try makeFormItem(field)) case "-u", "--user": let (username, password) = try splitOnce(try value(for: token), separator: ":") @@ -134,12 +152,15 @@ enum CURLCommandParser { maxRedirects = count case "-k", "--insecure": + try require(.disabledTLSVerification, for: token) isInsecure = true case "-x", "--proxy": + try require(.proxy, for: token) proxy = try makeProxy(try value(for: token)) case "--resolve": + try require(.dnsOverrides, for: token) let (host, address) = try makeDNSOverride(try value(for: token)) dnsOverrides[host] = address @@ -147,12 +168,15 @@ enum CURLCommandParser { isCompressed = true case "--cacert": + try require(.localFiles, for: token) caCertPath = try value(for: token) case "--cert", "-E": + try require(.localFiles, for: token) certPath = try value(for: token) case "--key": + try require(.localFiles, for: token) keyPath = try value(for: token) // Ignored outright, not routed through `value(for:)`/`RequestConfiguration` at all: @@ -451,6 +475,15 @@ enum CURLCommandParser { configuration.body = body } + /// Whether a `-F`/`--form` field is a file (`name=@path`), which is read from this machine. + private static func isFile(_ field: String) -> Bool { + guard let separator = field.firstIndex(of: "=") else { + return false + } + + return field[field.index(after: separator)...].trimming(where: \.isWhitespace).hasPrefix("@") + } + /// Parses one `-F`/`--form` field: `name=value`, or `name=@path;type=mime` for a file. private static func makeFormItem(_ field: String) throws -> FormItem { let (name, rawValue) = try splitOnce(field, separator: "=", context: field) diff --git a/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/Models/CURLParsingError.swift b/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/Models/CURLParsingError.swift index 417b76d1..59a48cb6 100644 --- a/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/Models/CURLParsingError.swift +++ b/Sources/RequestDL/Tasks/Sources/Raw Task/cURL/Models/CURLParsingError.swift @@ -12,7 +12,8 @@ public struct CURLParsingError: Error, Sendable, Hashable { public enum Context: Sendable, Hashable { /// A flag outside the documented subset (`-X`/`-H`/`-d`/`-F`/`-u`/`--url`/`-G`, plus - /// their long forms). + /// their long forms), or one that ``CURLTask/Capabilities`` the task was given do not + /// cover (`-F name=@path`, `-k`, `-x`, `--resolve`, `--cacert`, `--cert`, `--key`). case unsupportedFlag /// A flag that takes a value was the last token, or its value didn't parse (a `-F` diff --git a/Tests/RequestDLTests/Tasks/Sources/Raw Task/cURL/CURLTaskCapabilitiesTests.swift b/Tests/RequestDLTests/Tasks/Sources/Raw Task/cURL/CURLTaskCapabilitiesTests.swift new file mode 100644 index 00000000..28178247 --- /dev/null +++ b/Tests/RequestDLTests/Tasks/Sources/Raw Task/cURL/CURLTaskCapabilitiesTests.swift @@ -0,0 +1,212 @@ +// +// See LICENSE for this package's licensing information. +// + +import Testing + +@testable import RequestDL + +/// What a command may do when `CURLTask` is given `allowing:`: the flags that read a file, turn +/// certificate verification off, name a proxy or redirect DNS are refused unless the capability +/// that covers them is there. +struct CURLTaskCapabilitiesTests { + + // MARK: - Refused without the capability + + @Test(arguments: [ + ("curl -F file=@/etc/hosts https://example.com", "-F"), + ("curl --form file=@/etc/hosts https://example.com", "--form"), + ("curl -F 'file=@notes.txt;type=text/plain' https://example.com", "-F"), + ("curl --cacert /tmp/ca.pem https://example.com", "--cacert"), + ("curl --cert /tmp/client.pem https://example.com", "--cert"), + ("curl -E /tmp/client.pem https://example.com", "-E"), + ("curl --key /tmp/client.key https://example.com", "--key"), + ("curl -k https://example.com", "-k"), + ("curl --insecure https://example.com", "--insecure"), + ("curl -x http://proxy.example.com:8080 https://example.com", "-x"), + ("curl --proxy socks5://proxy.example.com https://example.com", "--proxy"), + ("curl --resolve example.com:443:127.0.0.1 https://example.com", "--resolve"), + ]) + func emptyCapabilities_refuseTheFlag(_ command: String, _ flag: String) async { + // When + let error = await #expect(throws: CURLParsingError.self) { + try await CURLCommandParser.parseCommand(command, allowing: []) + } + + // Then + #expect(error?.context == .unsupportedFlag) + #expect(error?.token == flag) + } + + /// A capability covers its own flags and nothing else. + @Test(arguments: [ + ("curl -k https://example.com", CURLTask.Capabilities.localFiles), + ("curl -k https://example.com", .proxy), + ("curl -x http://proxy.example.com:8080 https://example.com", .disabledTLSVerification), + ("curl --resolve example.com:443:127.0.0.1 https://example.com", .proxy), + ("curl --cacert /tmp/ca.pem https://example.com", .dnsOverrides), + ("curl -F file=@/etc/hosts https://example.com", .disabledTLSVerification), + ]) + func aCapability_doesNotCoverTheOtherFlags( + _ command: String, + _ capabilities: CURLTask.Capabilities + ) async { + // When + let error = await #expect(throws: CURLParsingError.self) { + try await CURLCommandParser.parseCommand(command, allowing: capabilities) + } + + // Then + #expect(error?.context == .unsupportedFlag) + } + + // MARK: - Allowed with the capability + + @Test(arguments: [ + ("curl --cacert /tmp/ca.pem https://example.com", CURLTask.Capabilities.localFiles), + ("curl --cert /tmp/client.pem --key /tmp/client.key https://example.com", .localFiles), + ("curl -k https://example.com", .disabledTLSVerification), + ("curl -x http://proxy.example.com:8080 https://example.com", .proxy), + ("curl --resolve example.com:443:127.0.0.1 https://example.com", .dnsOverrides), + ]) + func theMatchingCapability_allowsTheFlag( + _ command: String, + _ capabilities: CURLTask.Capabilities + ) async throws { + // When + let parsed = try await CURLCommandParser.parseCommand(command, allowing: capabilities) + + // Then + #expect(parsed.sessionConfigurationEdit != nil) + } + + @Test + func allCapabilities_allowEveryFlagTogether() async throws { + // Given + let command = """ + curl -k -x http://proxy.example.com:8080 --resolve example.com:443:127.0.0.1 \ + --cacert /tmp/ca.pem --cert /tmp/client.pem --key /tmp/client.key https://example.com + """ + + // When + let parsed = try await CURLCommandParser.parseCommand(command, allowing: .all) + + // Then + #expect(parsed.sessionConfigurationEdit != nil) + } + + /// `parseCommand(_:)` and `CURLTask(_:)` are what they were: all of it. + @Test + func withoutAllowing_everyFlagIsAllowed() async throws { + // Given + let command = "curl -k -x http://proxy.example.com:8080 --cacert /tmp/ca.pem https://example.com" + + // When + let parsed = try await CURLCommandParser.parseCommand(command) + + // Then + #expect(parsed.sessionConfigurationEdit != nil) + } + + // MARK: - What an empty set still allows + + /// Only what touches the machine or the connection needs a capability. A request described + /// with headers, a body, a form of plain fields, credentials and a redirect policy does not. + @Test + func emptyCapabilities_stillAllowAPlainRequest() async throws { + // Given + let command = """ + curl -X POST https://example.com/users -H 'Content-Type: application/json' \ + -d '{"name":"John"}' -u user:password -L --max-redirs 3 --compressed -s -o out.json + """ + + // When + let configuration = try await CURLCommandParser.parseCommand(command, allowing: []) + .requestConfiguration + + // Then + #expect(configuration.url == "https://example.com/users") + #expect(configuration.method == "POST") + } + + @Test + func emptyCapabilities_allowAFormOfPlainFields() async throws { + // Given + let command = "curl -F name=John -F 'bio=a@b.c' https://example.com/users" + + // When + let configuration = try await CURLCommandParser.parseCommand(command, allowing: []) + .requestConfiguration + + // Then + #expect(configuration.method == "POST") + #expect(configuration.body != nil) + } + + /// A `-F` field with no `=` is not a file, so it needs no capability to be refused: it is + /// malformed whatever the task may do. + @Test + func aFormFieldWithoutAnEquals_isMalformed_notRefusedForItsCapabilities() async { + // When + let error = await #expect(throws: CURLParsingError.self) { + try await CURLCommandParser.parseCommand("curl -F nofield https://example.com", allowing: []) + } + + // Then + #expect(error?.context == .malformedArgument) + #expect(error?.token == "nofield") + } + + // MARK: - Refused before anything is opened + + /// A path that does not exist: with the capability the parse fails opening it, which is not a + /// `CURLParsingError`; without it the flag is refused first, so the file was never touched. + @Test + func aRefusedFile_isNeverOpened() async throws { + // Given + let command = "curl -F file=@/nonexistent/request-dl-never-opened.bin https://example.com" + + // When + let refused = await #expect(throws: CURLParsingError.self) { + try await CURLCommandParser.parseCommand(command, allowing: []) + } + + let opened = await #expect(throws: (any Error).self) { + try await CURLCommandParser.parseCommand(command, allowing: .localFiles) + } + + // Then + #expect(refused?.context == .unsupportedFlag) + #expect(opened != nil) + #expect(opened is CURLParsingError == false) + } + + @Test + func theTask_refusesBeforeAnyNetworkIO() async { + // Given + let task = CURLTask("curl -k https://example.com", allowing: []) + + // When / Then + await #expect(throws: CURLParsingError.self) { + _ = try await task.result() + } + } + + // MARK: - The option set + + @Test + func capabilities_areDistinctBitsAndAllCoversThem() { + let each: [CURLTask.Capabilities] = [ + .localFiles, .disabledTLSVerification, .proxy, .dnsOverrides, + ] + + #expect(Set(each.map(\.rawValue)).count == each.count) + + for capability in each { + #expect(CURLTask.Capabilities.all.contains(capability)) + } + + #expect(CURLTask.Capabilities.all.rawValue == each.reduce(0) { $0 | $1.rawValue }) + #expect(CURLTask.Capabilities([]).isEmpty) + } +}