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 @@ -149,6 +149,81 @@ public async Task Rewrite_LeavesRequestHeader_WhenUrlDoesNotMatch()
Assert.Equal("probe=original", body);
}

[Fact]
public async Task Rewrite_ExpandsEnvironmentVariablesInUrlAndHeader()
{
var suffix = Guid.NewGuid().ToString("N");
var pathVar = $"DEVPROXY_TEST_PATH_{suffix}";
var headerVar = $"DEVPROXY_TEST_HEADER_{suffix}";
Environment.SetEnvironmentVariable(pathVar, "/head$1");
Environment.SetEnvironmentVariable(headerVar, "from-env");
try
{
await using var origin = await FakeOrigin.StartAsync();
var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
var config = PluginConfig.FromJson($$"""
{
"rewrites": [
{ "in": { "url": "/get(ers)$" }, "out": { "url": "${env:{{pathVar}}}", "headers": [ { "name": "X-Probe", "value": "v-${env:{{headerVar}}}" } ] } }
]
}
""");
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}/geters"));
var body = await response.Content.ReadAsStringAsync();

// /head + capture group "ers" referenced from the env var value.
Assert.Contains(origin.ReceivedRequests, r => r.PathAndQuery == "/headers");
Assert.Equal("probe=v-from-env", body);
}
finally
{
Environment.SetEnvironmentVariable(pathVar, null);
Environment.SetEnvironmentVariable(headerVar, null);
}
}

[Fact]
public async Task Rewrite_SkipsRule_WhenEnvironmentVariableMissing()
{
var missingVar = $"DEVPROXY_TEST_MISSING_{Guid.NewGuid():N}";
await using var origin = await FakeOrigin.StartAsync();
var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
var config = PluginConfig.FromJson($$"""
{
"rewrites": [
{ "in": { "url": "/headers$" }, "out": { "url": "/status/503", "headers": [ { "name": "X-Probe", "value": "${env:{{missingVar}}}" } ] } }
]
}
""");
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(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("probe=original", body);
}

[Fact]
public async Task Rewrite_RewritesWebSocketUrl_OriginReceivesRewrittenPath()
{
Expand Down
51 changes: 48 additions & 3 deletions DevProxy.Plugins/Manipulation/RewritePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public sealed class RewritePluginConfiguration
public string RewritesFile { get; set; } = "rewrites.json";
}

public sealed class RewritePlugin(
public sealed partial class RewritePlugin(
HttpClient httpClient,
ILogger<RewritePlugin> logger,
ISet<UrlToWatch> urlsToWatch,
Expand Down Expand Up @@ -106,9 +106,31 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
// 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 outUrl = rewrite.Out.Url;
if (urlMatches)
{
var newUrl = Regex.Replace(request.Url, rewrite.In.Url, rewrite.Out.Url, RegexOptions.IgnoreCase);
var missingVariables = new HashSet<string>(StringComparer.Ordinal);
outUrl = ExpandEnvironmentVariables(outUrl, missingVariables);
headers = [.. headers.Select(h => new RewriteHeader
{
Name = h.Name,
Value = ExpandEnvironmentVariables(h.Value, missingVariables)
})];

if (missingVariables.Count > 0)
{
Logger.LogError(
"Skipping rewrite {InUrl} for {RequestUrl}. The following environment variables aren't set: {MissingVariables}",
rewrite.In.Url,
request.Url,
string.Join(", ", missingVariables));
continue;
}
}

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

if (request.Url.Equals(newUrl, StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -143,6 +165,29 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
return Task.CompletedTask;
}

private static string? ExpandEnvironmentVariables(string? value, HashSet<string> missingVariables)
{
if (string.IsNullOrEmpty(value))
{
return value;
}

return EnvironmentVariableTokenRegex().Replace(value, match =>
{
var name = match.Groups["name"].Value;
var envValue = Environment.GetEnvironmentVariable(name);
if (envValue is null)
{
_ = missingVariables.Add(name);
return match.Value;
}
return envValue;
});
}

[GeneratedRegex(@"\$\{env:(?<name>[^}]+)\}")]
private static partial Regex EnvironmentVariableTokenRegex();

protected override void Dispose(bool disposing)
{
if (disposing)
Expand Down
4 changes: 2 additions & 2 deletions schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"url": {
"type": "string",
"pattern": "^.*$",
"description": "URL to rewrite the request to. Can use capture groups from the 'in' pattern. If omitted, the request URL isn't changed."
"description": "URL to rewrite the request to. Can use capture groups from the 'in' pattern and reference environment variables using ${env:VAR_NAME}. Environment variable values are inserted as-is, so they can also reference capture groups. If a referenced environment variable isn't set, the rule is skipped. If omitted, the request URL isn't changed."
},
"headers": {
"type": "array",
Expand All @@ -48,7 +48,7 @@
},
"value": {
"type": "string",
"description": "Literal value to set for the request header."
"description": "Value to set for the request header. Can reference environment variables using ${env:VAR_NAME}. If a referenced environment variable isn't set, the rule is skipped."
}
},
"required": ["name", "value"]
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 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.
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. `out.url` and header values can reference environment variables with `${env:VAR_NAME}` (values are inserted as-is, so in `out.url` they can reference capture groups); if a referenced variable isn't set, Dev Proxy logs an error and skips the rule.

| 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 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.
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. `out.url` and header values can reference environment variables with `${env:VAR_NAME}` (values are inserted as-is, so in `out.url` they can reference capture groups); if a referenced variable isn't set, Dev Proxy logs an error and skips the rule.

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