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
127 changes: 112 additions & 15 deletions Flowlight/Inspection/RewriteRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ struct HeaderEdit: Codable, Equatable, Identifiable, Sendable {
}
}

/// An edit to the request's JSON body, addressed by a dotted key path into objects (`metadata.user`). `set` creates
/// the path if needed; `remove` deletes the leaf. The value is parsed as JSON when it can be (`0.7`, `true`,
/// `{"a":1}`) and taken as a plain string otherwise.
/// An edit to the request's body. For a JSON body, `path` is a dotted key path into objects (`metadata.user`):
/// `set` creates the path if needed, `remove` deletes the leaf, and the value is parsed as JSON when it can be
/// (`0.7`, `true`, `{"a":1}`) and taken as a plain string otherwise. For a form body
/// (`application/x-www-form-urlencoded`), `path` is a field name taken whole and the value is used as typed.
struct BodyEdit: Codable, Equatable, Identifiable, Sendable {
enum Op: String, Codable, Sendable, CaseIterable { case set, remove }
var id = UUID()
Expand All @@ -46,8 +47,7 @@ struct BodyEdit: Codable, Equatable, Identifiable, Sendable {
}
}

/// A rule that rewrites a matching outgoing request before it is forwarded upstream — changing headers or the JSON
/// body. Like a mock, but it edits the request and lets it through rather than answering it: add an `Authorization`
/// A rule that rewrites a matching outgoing request before it is forwarded upstream — changing headers or the body (JSON or form). Like a mock, but it edits the request and lets it through rather than answering it: add an `Authorization`
/// header, pin `model`, strip a tracking field. Matched like a mock (host / path glob / method), and applied by the
/// same intervention path the guardrails use. Only requests Flowlight decrypts and can buffer (bodies up to a few MB,
/// not chunked or streamed) can be rewritten.
Expand Down Expand Up @@ -139,18 +139,35 @@ enum RewriteRules {
}

let bodyEdits = applicable.flatMap(\.body)
if !bodyEdits.isEmpty, !bodyData.isEmpty,
var json = (try? JSONSerialization.jsonObject(with: bodyData)) as? [String: Any] {
var changed = false
for edit in bodyEdits {
let comps = edit.path.split(separator: ".").map(String.init)
guard !comps.isEmpty else { continue }
switch edit.op {
case .set: setJSON(&json, path: comps, value: parseValue(edit.value)); changed = true; notes.append("set \(edit.path)")
case .remove: removeJSON(&json, path: comps); changed = true; notes.append("removed \(edit.path)")
if !bodyEdits.isEmpty, !bodyData.isEmpty {
if var json = (try? JSONSerialization.jsonObject(with: bodyData)) as? [String: Any] {
var changed = false
for edit in bodyEdits {
let comps = edit.path.split(separator: ".").map(String.init)
guard !comps.isEmpty else { continue }
switch edit.op {
case .set: setJSON(&json, path: comps, value: parseValue(edit.value)); changed = true; notes.append("set \(edit.path)")
case .remove: removeJSON(&json, path: comps); changed = true; notes.append("removed \(edit.path)")
}
}
if changed, let out = try? JSONSerialization.data(withJSONObject: json) { bodyData = out }
} else if isFormEncoded(headerLines), var form = FormBody(bodyData) {
// A form body is flat, so a path is a field name taken whole — `a.b` means a field literally
// named `a.b`, not a nested one. The value is always text; forms have no types, so it is used
// verbatim rather than through `parseValue`.
var changed = false
for edit in bodyEdits {
let field = edit.path
guard !field.isEmpty else { continue }
switch edit.op {
case .set:
form.set(field, to: edit.value); changed = true; notes.append("set \(field)")
case .remove:
if form.remove(field) { changed = true; notes.append("removed \(field)") }
}
}
if changed { bodyData = form.encoded() }
}
if changed, let out = try? JSONSerialization.data(withJSONObject: json) { bodyData = out }
}

guard !notes.isEmpty else { return nil }
Expand All @@ -174,6 +191,19 @@ enum RewriteRules {
return s
}

/// Whether the request carries an `application/x-www-form-urlencoded` body, from its Content-Type header.
///
/// A `charset` or other parameter may follow (`...; charset=utf-8`), so this matches the media type as a
/// prefix rather than the whole value.
static func isFormEncoded(_ headerLines: [String]) -> Bool {
for line in headerLines where line.lowercased().hasPrefix("content-type:") {
let value = line.drop(while: { $0 != ":" }).dropFirst()
.trimmingCharacters(in: .whitespaces).lowercased()
return value.hasPrefix("application/x-www-form-urlencoded")
}
return false
}

private static func setJSON(_ object: inout [String: Any], path: [String], value: Any) {
guard let key = path.first else { return }
if path.count == 1 { object[key] = value; return }
Expand All @@ -190,3 +220,70 @@ enum RewriteRules {
object[key] = child
}
}

/// An `application/x-www-form-urlencoded` body as its ordered `name=value` pairs.
///
/// Ordered rather than a dictionary, and able to hold the same name twice, because a form can — and a rewrite
/// that silently collapsed `tag=a&tag=b` into one field would change a request in a way nobody asked for. Set
/// edits the first pair of that name in place (or appends when there is none); remove drops every pair of that
/// name. Decoding and re-encoding follow the form rules: `+` is a space, everything else is percent-encoded.
struct FormBody {
private var pairs: [(name: String, value: String)]

/// Parses a form body. Fails only if the bytes are not UTF-8; an empty or shapeless body parses to no pairs,
/// which is a body a `set` can still add a field to.
init?(_ data: Data) {
guard let text = String(data: data, encoding: .utf8) else { return nil }
pairs = []
guard !text.isEmpty else { return }
for part in text.components(separatedBy: "&") where !part.isEmpty {
if let eq = part.firstIndex(of: "=") {
let name = Self.decode(String(part[part.startIndex..<eq]))
let value = Self.decode(String(part[part.index(after: eq)...]))
pairs.append((name, value))
} else {
// A field with no `=` is a name with an empty value, which is how browsers send a checkbox.
pairs.append((Self.decode(part), ""))
}
}
}

/// Replaces the first pair of this name, or appends one when there is none.
mutating func set(_ name: String, to value: String) {
if let index = pairs.firstIndex(where: { $0.name == name }) {
pairs[index].value = value
} else {
pairs.append((name, value))
}
}

/// Drops every pair of this name. Returns whether anything was dropped.
@discardableResult
mutating func remove(_ name: String) -> Bool {
let before = pairs.count
pairs.removeAll { $0.name == name }
return pairs.count != before
}

/// Re-encodes the pairs. Field order is preserved so a diff reads as the one change that was made.
func encoded() -> Data {
let body = pairs.map { "\(Self.encode($0.name))=\(Self.encode($0.value))" }.joined(separator: "&")
return Data(body.utf8)
}

/// Form-decode one component: `+` is a space, then `%XX` is a byte. A malformed `%` is left as written
/// rather than dropped, so a value is never silently truncated.
private static func decode(_ s: String) -> String {
s.replacingOccurrences(of: "+", with: " ").removingPercentEncoding
?? s.replacingOccurrences(of: "+", with: " ")
}

/// Form-encode one component. Everything outside the unreserved set is percent-encoded and space becomes
/// `+`, which is what a browser emits — so `http://localhost/admin` becomes `http%3A%2F%2Flocalhost%2Fadmin`.
private static func encode(_ s: String) -> String {
let unreserved = CharacterSet(charactersIn:
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._ ")
let percent = s.addingPercentEncoding(withAllowedCharacters: unreserved) ?? s
return percent.replacingOccurrences(of: " ", with: "+")
}
}
8 changes: 4 additions & 4 deletions Flowlight/UI/RewriteRulesView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import SwiftUI

/// Modify requests: rules that edit an outgoing request's headers or JSON body before it's forwarded. Sits with the
/// Modify requests: rules that edit an outgoing request's headers or body (JSON or form) before it's forwarded. Sits with the
/// rest of inspection setup — a rule can only change a request Flowlight decrypts.
struct RewriteRulesSection: View {
@ObservedObject var inspection: InspectionController
Expand All @@ -9,7 +9,7 @@ struct RewriteRulesSection: View {

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(L("Change a request on its way out — add or replace a header, pin a field in the JSON body, or strip one — and let it continue to the server. Like a mock, but it edits the request instead of answering it."))
Text(L("Change a request on its way out — add or replace a header, pin a field in the JSON or form body, or strip one — and let it continue to the server. Like a mock, but it edits the request instead of answering it."))
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
Label(L("Only requests Flowlight decrypts can be rewritten, and only buffered ones — bodies up to a few megabytes. Tunnelled, pinned, chunked or streamed uploads pass through untouched."),
systemImage: "info.circle")
Expand Down Expand Up @@ -155,7 +155,7 @@ struct RewriteRuleEditor: View {

// Body edits
VStack(alignment: .leading, spacing: 6) {
Text(L("JSON body")).font(.caption.bold()).foregroundStyle(.secondary)
Text(L("Request body")).font(.caption.bold()).foregroundStyle(.secondary)
ForEach($rule.body) { $edit in
HStack(spacing: 6) {
Picker("", selection: $edit.op) {
Expand All @@ -171,7 +171,7 @@ struct RewriteRuleEditor: View {
}
}
Button(L("Add body edit")) { rule.body.append(BodyEdit()) }.controlSize(.small)
Text(L("Dotted path into the JSON object (metadata.user). A value that is valid JSON (0.7, true, {\"a\":1}) is used as-is; anything else is a string. Only requests with a JSON body are changed."))
Text(L("For a JSON body, a dotted path into the object (metadata.user); a value that is valid JSON (0.7, true, {\"a\":1}) is used as-is, anything else is a string. For a form body (application/x-www-form-urlencoded), the field name, taken whole, set to the text as typed. Only requests with one of those two bodies are changed."))
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
}

Expand Down
77 changes: 77 additions & 0 deletions FlowlightTests/RewriteRuleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,83 @@ final class RewriteRuleTests: XCTestCase {
XCTAssertTrue(parts(out).head.contains("X-Tag: 1"))
}

// MARK: Form bodies

private let form = "Content-Type: application/x-www-form-urlencoded"

private func bodyForm(_ data: Data) -> [String: String] {
guard let sep = data.range(of: Data("\r\n\r\n".utf8)) else { return [:] }
let body = String(decoding: data[sep.upperBound...], as: UTF8.self)
var out: [String: String] = [:]
for part in body.components(separatedBy: "&") where !part.isEmpty {
let halves = part.components(separatedBy: "=")
let name = (halves.first ?? "").removingPercentEncoding ?? ""
let value = halves.dropFirst().joined(separator: "=")
.replacingOccurrences(of: "+", with: " ").removingPercentEncoding ?? ""
out[name] = value
}
return out
}

/// The reported case: a form field set to a URL, the value percent-encoded, Content-Length reframed. This is
/// what the "Set stockApi = http://localhost/admin" rule did nothing for, because the body is a form, not JSON.
func testFormSetEncodesValue() throws {
let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .set, path: "stockApi", value: "http://localhost/admin")])
let out = try XCTUnwrap(apply(rule, request("POST", "/product/stock", headers: [form],
body: "stockApi=http%3A%2F%2Fstock.example.net%3A8080%2Fcheck"),
path: "/product/stock"))
XCTAssertEqual(bodyForm(out)["stockApi"], "http://localhost/admin")
// The value is percent-encoded on the wire, and Content-Length agrees with the final body.
XCTAssertTrue(parts(out).body.contains("http%3A%2F%2Flocalhost%2Fadmin"))
let declared = parts(out).head.first { $0.lowercased().hasPrefix("content-length:") }?
.components(separatedBy: ": ").last.flatMap { Int($0) }
let sep = try XCTUnwrap(out.range(of: Data("\r\n\r\n".utf8)))
XCTAssertEqual(declared, out.distance(from: sep.upperBound, to: out.endIndex))
}

/// Set adds a field that was not there, leaving the others in place and in order.
func testFormSetAppendsNewField() throws {
let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .set, path: "role", value: "admin")])
let out = try XCTUnwrap(apply(rule, request("POST", "/login", headers: [form], body: "user=alice&pass=x"), path: "/login"))
XCTAssertEqual(bodyForm(out)["user"], "alice")
XCTAssertEqual(bodyForm(out)["pass"], "x")
XCTAssertEqual(bodyForm(out)["role"], "admin")
}

/// Remove drops a field; removing one that is not there is no change.
func testFormRemove() throws {
let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .remove, path: "csrf")])
let out = try XCTUnwrap(apply(rule, request("POST", "/login", headers: [form], body: "user=alice&csrf=tok"), path: "/login"))
XCTAssertNil(bodyForm(out)["csrf"])
XCTAssertEqual(bodyForm(out)["user"], "alice")

let miss = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .remove, path: "nope")])
XCTAssertNil(apply(miss, request("POST", "/login", headers: [form], body: "user=alice"), path: "/login"))
}

/// Set replaces the first pair of a repeated field and leaves the rest, rather than collapsing them.
func testFormSetReplacesInPlaceKeepingOrder() throws {
let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .set, path: "tag", value: "z")])
let out = try XCTUnwrap(apply(rule, request("POST", "/x", headers: [form], body: "tag=a&tag=b&keep=1"), path: "/x"))
let body = String(decoding: out[(out.range(of: Data("\r\n\r\n".utf8))!.upperBound)...], as: UTF8.self)
XCTAssertEqual(body, "tag=z&tag=b&keep=1")
}

/// Without the form Content-Type, a body shaped like a form is still left untouched — the header is the signal.
func testFormBodyWithoutContentTypeIsNotTouched() {
let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .set, path: "a", value: "2")])
XCTAssertNil(apply(rule, request("POST", "/x", body: "a=1&b=2"), path: "/x"))
}

/// A charset parameter after the media type does not stop it being recognised as a form.
func testFormContentTypeWithCharset() throws {
let rule = RewriteRule(host: "api.example.com", body: [BodyEdit(op: .set, path: "a", value: "2")])
let out = try XCTUnwrap(apply(rule, request("POST", "/x",
headers: ["Content-Type: application/x-www-form-urlencoded; charset=utf-8"],
body: "a=1"), path: "/x"))
XCTAssertEqual(bodyForm(out)["a"], "2")
}

// MARK: Matching

/// A rule for another host, method or path doesn't touch the request.
Expand Down
2 changes: 1 addition & 1 deletion docs/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ <h1>That page isn't here</h1><p class="lede">Try the <a href="/">home page</a>,
<div class="legal">
<span>© 2026 The Flowlight contributors. Flowlight is free software, released under the
<a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.13.0 · Not affiliated with Apple or any AI provider named on this site.</span>
<span>Version 0.13.1 · Not affiliated with Apple or any AI provider named on this site.</span>
</div>
</div>
</footer>
Expand Down
2 changes: 1 addition & 1 deletion docs/about/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ <h2 id="thanks">Thanks</h2>
<div class="legal">
<span>© 2026 The Flowlight contributors. Flowlight is free software, released under the
<a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.13.0 · Not affiliated with Apple or any AI provider named on this site.</span>
<span>Version 0.13.1 · Not affiliated with Apple or any AI provider named on this site.</span>
</div>
</div>
</footer>
Expand Down
2 changes: 1 addition & 1 deletion docs/de/about/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ <h2 id="thanks">Dank</h2>
</div>
<div class="legal">
<span>© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der <a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.13.0 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
<span>Version 0.13.1 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
</div>
</div>
</footer>
Expand Down
4 changes: 2 additions & 2 deletions docs/de/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
<section class="page-head"><div class="wrap">
<p class="eyebrow">Dokumentation</p>
<h1>Flowlight benutzen</h1>
<p class="lede">Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.13.0, macOS 15 oder neuer.</p>
<p class="lede">Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.13.1, macOS 15 oder neuer.</p>
</div></section>
<div class="wrap prose-wrap">
<nav class="toc" aria-label="Auf dieser Seite">
Expand Down Expand Up @@ -738,7 +738,7 @@ <h2 id="limits">Grenzen</h2>
</div>
<div class="legal">
<span>© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der <a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.13.0 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
<span>Version 0.13.1 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
</div>
</div>
</footer>
Expand Down
Loading
Loading