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
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,102 @@ public async Task Rewrite_RewritesRequestUrl_OriginServesRewrittenPath()
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}

[Theory]
[InlineData("original")]
[InlineData(null)]
public async Task Rewrite_SetsRequestHeader_WhenUrlMatches(string? originalValue)
{
await using var origin = await FakeOrigin.StartAsync();
var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
var config = PluginConfig.FromJson("""
{
"rewrites": [
{ "in": { "url": "/headers$" }, "out": { "headers": [ { "name": "x-probe", "value": "rewritten" } ] } }
]
}
""");
var plugin = new RewritePlugin(
SharedHttpClient,
NullLogger<RewritePlugin>.Instance,
urls,
ProxyConfig,
config);

await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
using var client = proxy.CreateHttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"http://{origin.Host}/headers"));
if (originalValue is not null)
{
request.Headers.Add("X-Probe", originalValue);
}

using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("probe=rewritten", body);
Assert.Contains(origin.ReceivedRequests, r => r.PathAndQuery == "/headers");
}

[Fact]
public async Task Rewrite_RewritesUrlAndHeader_MatchingHeaderAgainstOriginalUrl()
{
await using var origin = await FakeOrigin.StartAsync();
var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
var config = PluginConfig.FromJson("""
{
"rewrites": [
{ "in": { "url": "/get$" }, "out": { "url": "/headers", "headers": [ { "name": "X-Probe", "value": "rewritten" } ] } }
]
}
""");
var plugin = new RewritePlugin(
SharedHttpClient,
NullLogger<RewritePlugin>.Instance,
urls,
ProxyConfig,
config);

await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
using var client = proxy.CreateHttpClient();

using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
var body = await response.Content.ReadAsStringAsync();

Assert.Contains(origin.ReceivedRequests, r => r.PathAndQuery == "/headers");
Assert.Equal("probe=rewritten", body);
}

[Fact]
public async Task Rewrite_LeavesRequestHeader_WhenUrlDoesNotMatch()
{
await using var origin = await FakeOrigin.StartAsync();
var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
var config = PluginConfig.FromJson("""
{
"rewrites": [
{ "in": { "url": "/other$" }, "out": { "headers": [ { "name": "X-Probe", "value": "rewritten" } ] } }
]
}
""");
var plugin = new RewritePlugin(
SharedHttpClient,
NullLogger<RewritePlugin>.Instance,
urls,
ProxyConfig,
config);

await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
using var client = proxy.CreateHttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"http://{origin.Host}/headers"));
request.Headers.Add("X-Probe", "original");

using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();

Assert.Equal("probe=original", body);
}

[Fact]
public async Task Rewrite_RewritesWebSocketUrl_OriginReceivesRewrittenPath()
{
Expand Down
54 changes: 46 additions & 8 deletions DevProxy.Plugins/Manipulation/RewritePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@

namespace DevProxy.Plugins.Manipulation;

public sealed class RewriteHeader
{
public string? Name { get; set; }
public string? Value { get; set; }
}

public sealed class RewriteRule
{
public string? Url { get; set; }
public IEnumerable<RewriteHeader>? Headers { get; set; }
}

public sealed class RequestRewrite
Expand Down Expand Up @@ -82,22 +89,53 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca

foreach (var rewrite in Configuration.Rewrites)
{
if (string.IsNullOrEmpty(rewrite.In?.Url) ||
string.IsNullOrEmpty(rewrite.Out?.Url))
if (string.IsNullOrEmpty(rewrite.In?.Url) || rewrite.Out is null)
{
continue;
}

var headers = rewrite.Out.Headers?
.Where(h => h is not null && !string.IsNullOrEmpty(h.Name) && h.Value is not null)
.ToArray() ?? [];
if (string.IsNullOrEmpty(rewrite.Out.Url) && headers.Length == 0)
{
continue;
}

var newUrl = Regex.Replace(request.Url, rewrite.In.Url, rewrite.Out.Url, RegexOptions.IgnoreCase);
// Match against the URL before this rule rewrites it, so that
// header rewrites apply to the same requests as the URL rewrite.
var urlMatches = Regex.IsMatch(request.Url, rewrite.In.Url, RegexOptions.IgnoreCase);

if (!string.IsNullOrEmpty(rewrite.Out.Url))
{
var newUrl = Regex.Replace(request.Url, rewrite.In.Url, rewrite.Out.Url, RegexOptions.IgnoreCase);

if (request.Url.Equals(newUrl, StringComparison.OrdinalIgnoreCase))
{
Logger.LogRequest($"{rewrite.In.Url}", MessageType.Skipped, new LoggingContext(e.ProxySession));
}
else
{
Logger.LogRequest($"{rewrite.In.Url} > {newUrl}", MessageType.Processed, new LoggingContext(e.ProxySession));
request.Url = newUrl;
}
}

if (headers.Length == 0)
{
continue;
}

if (request.Url.Equals(newUrl, StringComparison.OrdinalIgnoreCase))
if (!urlMatches)
{
Logger.LogRequest($"{rewrite.In?.Url}", MessageType.Skipped, new LoggingContext(e.ProxySession));
Logger.LogRequest($"{rewrite.In.Url} (headers)", MessageType.Skipped, new LoggingContext(e.ProxySession));
continue;
}
else

foreach (var header in headers)
{
Logger.LogRequest($"{rewrite.In?.Url} > {newUrl}", MessageType.Processed, new LoggingContext(e.ProxySession));
request.Url = newUrl;
request.Headers.Replace(header.Name!, header.Value!);
Logger.LogRequest($"{rewrite.In.Url} > header {header.Name}", MessageType.Processed, new LoggingContext(e.ProxySession));
}
}

Expand Down
26 changes: 24 additions & 2 deletions schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,32 @@
"url": {
"type": "string",
"pattern": "^.*$",
"description": "URL to rewrite the request to. Can use capture groups from the 'in' pattern."
"description": "URL to rewrite the request to. Can use capture groups from the 'in' pattern. If omitted, the request URL isn't changed."
},
"headers": {
"type": "array",
"description": "Request headers to set on requests matching the 'in' URL. Replaces the value of an existing header (matched case-insensitively) or adds the header if it's missing.",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"pattern": "^.+$",
"description": "Name of the request header."
},
"value": {
"type": "string",
"description": "Literal value to set for the request header."
}
},
"required": ["name", "value"]
}
}
},
"required": ["url"]
"anyOf": [
{ "required": ["url"] },
{ "required": ["headers"] }
]
}
},
"required": ["in", "out"]
Expand Down
2 changes: 1 addition & 1 deletion skills/dev-proxy/references/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ None of these have configuration properties.

### RewritePlugin

Rewrites request URLs using regex capture groups.
Rewrites request URLs using regex capture groups and sets request header values. Each rule matches on `in.url`; `out.url` (optional) rewrites the URL, and `out.headers` (optional, `[{ "name", "value" }]`) replaces the header value or adds the header if it's missing.

| Property | Default | Description |
|----------|---------|-------------|
Expand Down
2 changes: 1 addition & 1 deletion skills/dev-proxy/references/plugin-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Simulates token-based rate limiting for LLM APIs.

### RewritePlugin

Rewrites request URLs using regex capture groups.
Rewrites request URLs using regex capture groups and sets request header values. Each rule matches on `in.url`; `out.url` (optional) rewrites the URL, and `out.headers` (optional, `[{ "name", "value" }]`) replaces the header value or adds the header if it's missing.

| Property | Default | Description |
|----------|---------|-------------|
Expand Down
Loading