From 36a7540936957c0a391fa7f50902cb13d141e882 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 7 Oct 2026 10:59:01 +0200 Subject: [PATCH] Support environment variables in RewritePlugin replacement values Allow out.url and out.headers[].value to reference environment variables using ${env:VAR_NAME}. Values are inserted as-is, so in out.url they can reference capture groups from the in.url pattern. If a referenced variable isn't set, the plugin logs an error and skips the rule. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a8d2f415-72ca-407f-ac8c-d88703338613 --- .../ManipulationAndMockingIntegrationTests.cs | 75 +++++++++++++++++++ .../Manipulation/RewritePlugin.cs | 51 ++++++++++++- .../rewriteplugin.rewritesfile.schema.json | 4 +- skills/dev-proxy/references/configuration.md | 2 +- skills/dev-proxy/references/plugin-catalog.md | 2 +- 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs index a4159276..c698aefc 100644 --- a/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs +++ b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs @@ -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.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.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() { diff --git a/DevProxy.Plugins/Manipulation/RewritePlugin.cs b/DevProxy.Plugins/Manipulation/RewritePlugin.cs index 2f208c70..0cfb194f 100644 --- a/DevProxy.Plugins/Manipulation/RewritePlugin.cs +++ b/DevProxy.Plugins/Manipulation/RewritePlugin.cs @@ -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 logger, ISet urlsToWatch, @@ -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(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)) { @@ -143,6 +165,29 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca return Task.CompletedTask; } + private static string? ExpandEnvironmentVariables(string? value, HashSet 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:(?[^}]+)\}")] + private static partial Regex EnvironmentVariableTokenRegex(); + protected override void Dispose(bool disposing) { if (disposing) diff --git a/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json b/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json index 9a46c0e5..44f55f3d 100644 --- a/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json +++ b/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json @@ -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", @@ -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"] diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md index ce5bcb9e..02a2ef2b 100644 --- a/skills/dev-proxy/references/configuration.md +++ b/skills/dev-proxy/references/configuration.md @@ -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 | |----------|---------|-------------| diff --git a/skills/dev-proxy/references/plugin-catalog.md b/skills/dev-proxy/references/plugin-catalog.md index f67f7cb7..412a4680 100644 --- a/skills/dev-proxy/references/plugin-catalog.md +++ b/skills/dev-proxy/references/plugin-catalog.md @@ -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 | |----------|---------|-------------|