From 224a0047576377e2c9133e5611fe5148890fa13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 2 Jun 2026 13:53:55 +0200 Subject: [PATCH 1/4] bump 3.16.2 --- .github/workflows/build-test-publish.yml | 2 +- npm/postinstall.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 5bd465d5..67637a2c 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: env: - RELEASE_VERSION: v3.16.1 + RELEASE_VERSION: v3.16.2 # Add workflow-level permissions permissions: diff --git a/npm/postinstall.js b/npm/postinstall.js index 60c4eccb..ae11d32f 100644 --- a/npm/postinstall.js +++ b/npm/postinstall.js @@ -5,7 +5,7 @@ const path = require("path"); const os = require("os"); const https = require("https"); -const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.16.1/"; +const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.16.2/"; // Download binary next to this script, not to ../.bin/ const binDir = path.join(__dirname, "bin"); From fff6e658edbf070a3d6053a56b8ec0162d5a600a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 3 Jun 2026 12:28:40 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20v3.16.3=20=E2=80=94=20static-conten?= =?UTF-8?q?t=20env-var=20injection=20(AvailableEnvVars)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AvailableEnvVars under StaticFiles:ParseContentOptions. Listed env vars are resolved once at parser construction, JSON-escaped (like claims) and templated into static content via the existing {NAME} tag machinery. Aimed at SPAs on K8s: inject per-environment values (build label, feature flags, analytics IDs) from pod env vars without rebuilding the bundle. - Both AvailableEnvVars and AvailableClaims now accept either an array of names or an object of name->default pairs. Missing env var falls back to its configured default, then the empty string; missing claim falls back to its default, then NULL (historical behaviour). - Values resolved once at startup (pod restart re-reads); claims win over env vars on name collision. - Allowlist-only: only listed names are read, never the whole environment. Distinct from the server-side Config:ParseEnvironmentVariables path. New Config.GetConfigNameDefaults reader handles both forms. Registered the key in ConfigDefaults, ConfigTemplate, ConfigSchemaGenerator and appsettings.json. Tests: 10 parser unit tests + 2 config-validation tests. --- NpgsqlRestClient/App.cs | 6 +- NpgsqlRestClient/AppStaticFileMiddleware.cs | 5 +- NpgsqlRestClient/Config.cs | 55 ++ NpgsqlRestClient/ConfigDefaults.cs | 1 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 3 +- NpgsqlRestClient/ConfigTemplate.cs | 7 + NpgsqlRestClient/DefaultResponseParser.cs | 51 +- NpgsqlRestClient/appsettings.json | 7 + .../ConfigTests/ConfigValidationTests.cs | 42 ++ .../ConfigTests/ResponseParserEnvVarTests.cs | 193 +++++++ .../Setup/CompressionTestFixture.cs | 3 +- .../Setup/StaticFilesTestFixture.cs | 3 +- STATIC_CONTENT_ENV_VAR_INJECTION.md | 479 ++++++++++++++++++ changelog/v3.16.3.md | 52 ++ 14 files changed, 896 insertions(+), 11 deletions(-) create mode 100644 NpgsqlRestTests/ConfigTests/ResponseParserEnvVarTests.cs create mode 100644 STATIC_CONTENT_ENV_VAR_INJECTION.md create mode 100644 changelog/v3.16.3.md diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index f774d5f2..64caad08 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -123,8 +123,9 @@ public void ConfigureStaticFiles(WebApplication app, NpgsqlRestAuthenticationOpt var filePaths = _config.GetConfigEnumerable("FilePaths", parseCfg)?.ToArray(); var antiforgeryFieldNameTag = _config.GetConfigStr("AntiforgeryFieldName", parseCfg); var antiforgeryTokenTag = _config.GetConfigStr("AntiforgeryToken", parseCfg); - var availableClaims = _config.GetConfigEnumerable("AvailableClaims", parseCfg)?.ToArray(); - + var availableClaims = _config.GetConfigNameDefaults("AvailableClaims", parseCfg); + var availableEnvVars = _config.GetConfigNameDefaults("AvailableEnvVars", parseCfg); + var antiforgery = app.Services.GetService(); AppStaticFileMiddleware.ConfigureStaticFileMiddleware( @@ -140,6 +141,7 @@ public void ConfigureStaticFiles(WebApplication app, NpgsqlRestAuthenticationOpt unauthorizedRedirectPath, unauthorizedReturnToQueryParameter, availableClaims, + availableEnvVars, _builder.ClientLogger); app.UseMiddleware(); diff --git a/NpgsqlRestClient/AppStaticFileMiddleware.cs b/NpgsqlRestClient/AppStaticFileMiddleware.cs index 6956cee2..b248e452 100644 --- a/NpgsqlRestClient/AppStaticFileMiddleware.cs +++ b/NpgsqlRestClient/AppStaticFileMiddleware.cs @@ -47,7 +47,8 @@ public static void ConfigureStaticFileMiddleware( string[]? authorizePaths, string? unauthorizedRedirectPath, string? unautorizedReturnToQueryParameter, - string[]? availableClaimTypes, + Dictionary? availableClaims, + Dictionary? availableEnvVars, ILogger? logger) { _parsePatterns = parse == false || parsePatterns == null || parsePatterns.Length == 0 ? null : parsePatterns?.Where(p => !string.IsNullOrEmpty(p)).ToArray(); @@ -57,7 +58,7 @@ public static void ConfigureStaticFileMiddleware( } else { - _parser = new DefaultResponseParser(options, antiforgeryFieldNameTag, antiforgeryTokenTag, availableClaimTypes); + _parser = new DefaultResponseParser(options, antiforgeryFieldNameTag, antiforgeryTokenTag, availableClaims, availableEnvVars); } _antiforgery = antiforgery; diff --git a/NpgsqlRestClient/Config.cs b/NpgsqlRestClient/Config.cs index 0b2376bb..ff37af38 100644 --- a/NpgsqlRestClient/Config.cs +++ b/NpgsqlRestClient/Config.cs @@ -262,6 +262,61 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de return result; } + /// + /// Reads a config section that accepts BOTH an array of names and an object of name→default pairs, + /// returning a name→default map. Used by AvailableClaims and AvailableEnvVars under + /// StaticFiles:ParseContentOptions. + /// + /// Array form ["A","B"] → IConfiguration exposes numeric child keys; each name maps to + /// null meaning "no explicit default" (the caller supplies the per-feed fallback). + /// Object form {"A":"x","B":""} → each name maps to its configured default value. + /// + /// Env-var tokens inside values are substituted via consistent with the rest + /// of the config reader. Returns null when the section is absent or empty. + /// + public Dictionary? GetConfigNameDefaults(string key, IConfiguration? subsection = null) + { + var section = subsection is not null ? subsection.GetSection(key) : Cfg.GetSection(key); + if (section.Exists() is false) + { + return null; + } + var children = section.GetChildren().ToArray(); + if (children.Length == 0) + { + return null; + } + + // Array form when every child key is a numeric index (how IConfiguration represents a JSON array). + bool arrayForm = children.All(c => int.TryParse(c.Key, out _)); + var result = new Dictionary(children.Length); + if (arrayForm) + { + foreach (var c in children) + { + if (string.IsNullOrEmpty(c.Value)) + { + continue; + } + var name = EnvDict is not null ? Formatter.FormatString(c.Value.AsSpan(), EnvDict).ToString() : c.Value!; + result[name] = null; + } + } + else + { + foreach (var c in children) + { + if (c.Value is null) + { + continue; // skip nested objects/arrays - only scalar name→default pairs are valid here + } + var def = EnvDict is not null ? Formatter.FormatString(c.Value.AsSpan(), EnvDict).ToString() : c.Value; + result[c.Key] = def; + } + } + return result.Count == 0 ? null : result; + } + public Dictionary? GetConfigDict(IConfiguration config) { var result = new Dictionary(); diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 127e93f2..2c7c4dc9 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -432,6 +432,7 @@ private static JsonObject GetStaticFilesDefaults() { ["Enabled"] = false, ["AvailableClaims"] = new JsonArray(), + ["AvailableEnvVars"] = new JsonArray(), ["CacheParsedFile"] = true, ["Headers"] = CreateStringArray("Cache-Control: no-store, no-cache, must-revalidate", "Pragma: no-cache", "Expires: 0"), ["FilePaths"] = CreateStringArray("*.html"), diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index e782d9eb..2fe640ad 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -193,7 +193,8 @@ public static partial class ConfigSchemaGenerator ["StaticFiles"] = "Static files settings", ["StaticFiles:AuthorizePaths"] = "List of static file patterns that will require authorization.\nFile paths are relative to the RootPath property and pattern matching is case-insensitive.\nPattern can include wildcards (* matches any chars, ** matches recursively including /, ? matches single char).\nFor example: *.html, /user/*, /admin/**/*.html", ["StaticFiles:ParseContentOptions:Enabled"] = "Enable or disable the parsing of the static files.\nWhen enabled, the static files will be parsed and the tags will be replaced with the values from the claims collection.\nThe tags are in the format: {claimType} where claimType is the name of the claim that will be replaced with the value from the claims collection.", - ["StaticFiles:ParseContentOptions:AvailableClaims"] = "List of claims types used. These will be parsed to NULL if not found in the claims collection or user is not authenticated.", + ["StaticFiles:ParseContentOptions:AvailableClaims"] = "List of claims types used. These will be parsed to NULL if not found in the claims collection or user is not authenticated.\nAccepts an array of claim names [\"name\",\"email\"] or an object of name->default {\"name\":\"guest\"} where the default value is substituted when the claim is absent.", + ["StaticFiles:ParseContentOptions:AvailableEnvVars"] = "List of environment variable names whose values are templated into static content using the same {NAME} tag syntax as claims.\nResolved once at application startup (a value change requires a restart). Accepts an array of names [\"BUILD_LABEL\"] (a missing variable becomes the empty string) or an object of name->default {\"DEMO_FLAG\":\"false\"} where the default is used when the variable is absent.\nValues are JSON-escaped, so templates use a bare {NAME} token with no surrounding quotes.\nSECURITY: every listed value is served to any client - never list a secret (database password, API key, signing token).", ["StaticFiles:ParseContentOptions:CacheParsedFile"] = "Set to true to cache the parsed files in memory. This will improve the performance of the static files. It only applies to parsed content.\nNote: caching will occur before parsing, it applies only to templates, not parsed content.", ["StaticFiles:ParseContentOptions:Headers"] = "Headers to be added to the response for static files. Set to null or empty array to ignore.", ["StaticFiles:ParseContentOptions:FilePaths"] = "List of static file patterns that will parse the content and replace the tags with the values from the claims collection.\nFile paths are relative to the RootPath property and pattern matching is case-insensitive.\nPattern can include wildcards (* matches any chars, ** matches recursively including /, ? matches single char).\nFor example: *.html, *.htm, /pages/**/*.html", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 19381525..f1036b81 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1105,9 +1105,16 @@ public static partial class ConfigSchemaGenerator "Enabled": false, // // List of claims types used. These will be parsed to NULL if not found in the claims collection or user is not authenticated. + // Accepts an array of claim names ["name","email"] or an object of name->default {"name":"guest"} where the default is used when the claim is absent. // "AvailableClaims": [], // + // List of environment variable names whose values are templated into static content (the same {NAME} tag syntax as claims). + // Resolved once at startup. Accepts an array ["BUILD_LABEL"] (missing -> empty string) or an object {"DEMO_FLAG":"false"} with per-name defaults. + // SECURITY: every listed value is served to any client - never list a secret (DB password, API key, signing token). + // + "AvailableEnvVars": [], + // // Set to true to cache the parsed files in memory. This will improve the performance of the static files. It only applies to parsed content. // Note: caching will occur before parsing, it applies only to templates, not parsed content. // diff --git a/NpgsqlRestClient/DefaultResponseParser.cs b/NpgsqlRestClient/DefaultResponseParser.cs index 3c60e6fa..f076ca51 100644 --- a/NpgsqlRestClient/DefaultResponseParser.cs +++ b/NpgsqlRestClient/DefaultResponseParser.cs @@ -8,8 +8,39 @@ public class DefaultResponseParser( NpgsqlRestAuthenticationOptions options, string? antiforgeryFieldNameTag, string? antiforgeryTokenTag, - string[]? availableClaimTypes) + Dictionary? availableClaims, + Dictionary? availableEnvVars = null) { + // Env vars are resolved ONCE at construction. They don't change within the pod's lifetime, so + // re-reading per request would just add System.Environment overhead with no payoff. A K8s pod + // restart re-instantiates the middleware (and thus this parser), which re-reads the values. + private readonly Dictionary _envVarReplacements = ResolveEnvVars(availableEnvVars); + + private static Dictionary ResolveEnvVars(Dictionary? envVars) + { + if (envVars is null || envVars.Count == 0) + { + return []; + } + var result = new Dictionary(envVars.Count); + foreach (var (name, def) in envVars) + { + // present env var wins → configured default → empty string. + var raw = Environment.GetEnvironmentVariable(name) ?? def ?? string.Empty; + // JSON-escape exactly like the claim path below (PgConverters.SerializeString). The + // substituted value is a complete JSON literal (a quoted, escaped string) so templates + // use a bare {NAME} token with no surrounding quotes and an accidental quote/backslash in + // the value cannot break the JS string. NOTE: the relaxed encoder does NOT escape '<'/'>', + // so this is not a defence against a hostile value - env values are operator-controlled, + // matching the claim path's trust model. + // SECURITY: this is an explicit allowlist - only names listed in AvailableEnvVars are ever + // read. NEVER widen this to the whole environment (Environment.GetEnvironmentVariables) - + // that would leak secrets (DB passwords, keys) to every client. + result[name] = PgConverters.SerializeString(raw); + } + return result; + } + public ReadOnlySpan Parse( ReadOnlySpan input, HttpContext context, @@ -67,11 +98,23 @@ public ReadOnlySpan Parse( replacements.Add(antiforgeryTokenTag, tokenSet.RequestToken); } } - if (availableClaimTypes is not null && availableClaimTypes.Length > 0) + // Listed-but-absent claims fall back to their configured default, or Consts.Null (the + // historical behaviour) when the array form gives no explicit default. Claim values added + // above from context.User.Claims are already present and win via TryAdd. + if (availableClaims is not null && availableClaims.Count > 0) + { + foreach (var (name, def) in availableClaims) + { + replacements.TryAdd(name, def ?? Consts.Null); + } + } + // Feed app-wide env-var values into the same dictionary. Per-request claim values added above + // WIN over env vars if the names ever collide (TryAdd does not overwrite). + if (_envVarReplacements.Count > 0) { - for (int i = 0; i < availableClaimTypes.Length; i++) + foreach (var (name, value) in _envVarReplacements) { - replacements.TryAdd(availableClaimTypes[i], Consts.Null); + replacements.TryAdd(name, value); } } return Formatter.FormatString(input, replacements); diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 6989ad58..42251d47 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1096,9 +1096,16 @@ "Enabled": false, // // List of claims types used. These will be parsed to NULL if not found in the claims collection or user is not authenticated. + // Accepts an array of claim names ["name","email"] or an object of name->default {"name":"guest"} where the default is used when the claim is absent. // "AvailableClaims": [], // + // List of environment variable names whose values are templated into static content (the same {NAME} tag syntax as claims). + // Resolved once at startup. Accepts an array ["BUILD_LABEL"] (missing -> empty string) or an object {"DEMO_FLAG":"false"} with per-name defaults. + // SECURITY: every listed value is served to any client - never list a secret (DB password, API key, signing token). + // + "AvailableEnvVars": [], + // // Set to true to cache the parsed files in memory. This will improve the performance of the static files. It only applies to parsed content. // Note: caching will occur before parsing, it applies only to templates, not parsed content. // diff --git a/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs b/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs index 770b70ec..fe236bee 100644 --- a/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs +++ b/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs @@ -651,4 +651,46 @@ public void ValidationRule_ExampleNames_StillValidate() warnings.Should().BeEmpty(); } + + [Fact] + public void ParseContentOptions_AvailableEnvVars_ArrayForm_ValidatesClean() + { + var actual = ActualFromJson(""" + { + "StaticFiles": { + "ParseContentOptions": { + "Enabled": true, + "AvailableClaims": [ "user_id", "user_name" ], + "AvailableEnvVars": [ "BUILD_LABEL", "DEMO_FLAG", "TRACKING_ID" ] + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + + [Fact] + public void ParseContentOptions_AvailableEnvVars_ObjectForm_ValidatesClean() + { + // The object (name->default) form must not trip the unknown-key validator: the user-chosen + // env-var names are arbitrary and must not be looked up against the defaults schema. + var actual = ActualFromJson(""" + { + "StaticFiles": { + "ParseContentOptions": { + "Enabled": true, + "AvailableClaims": { "user_id": "0", "user_name": "guest" }, + "AvailableEnvVars": { "BUILD_LABEL": "local", "DEMO_FLAG": "false" } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } } diff --git a/NpgsqlRestTests/ConfigTests/ResponseParserEnvVarTests.cs b/NpgsqlRestTests/ConfigTests/ResponseParserEnvVarTests.cs new file mode 100644 index 00000000..b2e603f7 --- /dev/null +++ b/NpgsqlRestTests/ConfigTests/ResponseParserEnvVarTests.cs @@ -0,0 +1,193 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using NpgsqlRest.Auth; +using NpgsqlRestClient; + +namespace NpgsqlRestTests.ConfigTests; + +/// +/// Unit tests for the env-var injection path in . +/// Env vars named in StaticFiles:ParseContentOptions:AvailableEnvVars are resolved once at +/// parser construction, JSON-escaped (like claims), and substituted into the same {NAME} tag +/// machinery the claim path uses. See STATIC_CONTENT_ENV_VAR_INJECTION.md. +/// +public class ResponseParserEnvVarTests +{ + private static readonly NpgsqlRestAuthenticationOptions Options = new(); + + private static DefaultResponseParser Parser( + Dictionary? claims = null, + Dictionary? envVars = null) => + new(Options, antiforgeryFieldNameTag: null, antiforgeryTokenTag: null, claims, envVars); + + private static HttpContext Anonymous() + { + var ctx = new DefaultHttpContext(); + ctx.User = new ClaimsPrincipal(new ClaimsIdentity()); + return ctx; + } + + private static HttpContext WithClaims(params (string type, string value)[] claims) + { + var ctx = new DefaultHttpContext(); + ctx.User = new ClaimsPrincipal(new ClaimsIdentity( + claims.Select(c => new Claim(c.type, c.value)), authenticationType: "test")); + return ctx; + } + + private static string Parse(DefaultResponseParser parser, string input, HttpContext ctx) => + parser.Parse(input.AsSpan(), ctx, tokenSet: null).ToString(); + + [Fact] + public void EmptyAvailableEnvVars_PassesContentThrough() + { + var parser = Parser(envVars: null); + const string input = "buildLabel: {BUILD_LABEL_EMPTY_CASE};"; + // No env vars configured -> the {BUILD_LABEL_EMPTY_CASE} token is unknown and left verbatim. + Parse(parser, input, Anonymous()).Should().Be("buildLabel: {BUILD_LABEL_EMPTY_CASE};"); + } + + [Fact] + public void EnvVarPresent_SubstitutesJsonEscapedValue() + { + const string name = "NPGSQLREST_TEST_BUILD_LABEL"; + Environment.SetEnvironmentVariable(name, "demo"); + try + { + var parser = Parser(envVars: new() { [name] = null }); + // bare token -> the substituted value is a complete, quoted JSON literal + Parse(parser, $"buildLabel: {{{name}}};", Anonymous()).Should().Be("buildLabel: \"demo\";"); + } + finally + { + Environment.SetEnvironmentVariable(name, null); + } + } + + [Fact] + public void EnvVarMissing_ArrayForm_BecomesEmptyJsonString() + { + const string name = "NPGSQLREST_TEST_MISSING_NO_DEFAULT"; + Environment.SetEnvironmentVariable(name, null); // ensure absent + var parser = Parser(envVars: new() { [name] = null }); + // missing + no default -> empty string -> JSON "" (never the literal text "null") + Parse(parser, $"x: {{{name}}};", Anonymous()).Should().Be("x: \"\";"); + } + + [Fact] + public void EnvVarMissing_ObjectFormDefault_UsesDefault() + { + const string name = "NPGSQLREST_TEST_MISSING_WITH_DEFAULT"; + Environment.SetEnvironmentVariable(name, null); // ensure absent + var parser = Parser(envVars: new() { [name] = "false" }); + Parse(parser, $"demoMode: {{{name}}} === \"true\";", Anonymous()) + .Should().Be("demoMode: \"false\" === \"true\";"); + } + + [Fact] + public void EnvVarValueWithQuotes_IsJsonEscaped() + { + const string name = "NPGSQLREST_TEST_ESCAPE"; + Environment.SetEnvironmentVariable(name, "a\"b\\c"); + try + { + var parser = Parser(envVars: new() { [name] = null }); + // " -> \" and \ -> \\ , so it stays a single well-formed JS string literal + Parse(parser, $"v: {{{name}}};", Anonymous()).Should().Be("v: \"a\\\"b\\\\c\";"); + } + finally + { + Environment.SetEnvironmentVariable(name, null); + } + } + + [Fact] + public void MultipleEnvVars_AllSubstitutedInOnePass() + { + const string a = "NPGSQLREST_TEST_MULTI_A"; + const string b = "NPGSQLREST_TEST_MULTI_B"; + Environment.SetEnvironmentVariable(a, "one"); + Environment.SetEnvironmentVariable(b, "two"); + try + { + var parser = Parser(envVars: new() { [a] = null, [b] = null }); + Parse(parser, $"{{{a}}}-{{{b}}}", Anonymous()).Should().Be("\"one\"-\"two\""); + } + finally + { + Environment.SetEnvironmentVariable(a, null); + Environment.SetEnvironmentVariable(b, null); + } + } + + [Fact] + public void ClaimAndEnvVarNameCollision_ClaimWins() + { + const string name = "shared_token"; + Environment.SetEnvironmentVariable(name, "from_env"); + try + { + var parser = Parser(envVars: new() { [name] = null }); + // a claim of the same name is present -> per-request claim value wins over the env var + var result = Parse(parser, $"v: {{{name}}};", WithClaims((name, "from_claim"))); + result.Should().Be("v: \"from_claim\";"); + } + finally + { + Environment.SetEnvironmentVariable(name, null); + } + } + + [Fact] + public void AnonymousRequest_EnvVarsStillSubstituted() + { + const string name = "NPGSQLREST_TEST_ANON"; + Environment.SetEnvironmentVariable(name, "anon_ok"); + try + { + var parser = Parser(envVars: new() { [name] = null }); + Parse(parser, $"v: {{{name}}};", Anonymous()).Should().Be("v: \"anon_ok\";"); + } + finally + { + Environment.SetEnvironmentVariable(name, null); + } + } + + [Fact] + public void ClaimObjectFormDefault_UsedWhenClaimAbsent() + { + // listed-but-absent claim with an explicit default -> default, not null + var withDefault = Parser(claims: new() { ["plan"] = "free" }); + Parse(withDefault, "plan: {plan};", Anonymous()).Should().Be("plan: free;"); + + // array form (no default) -> historical null behaviour + var arrayForm = Parser(claims: new() { ["plan"] = null }); + Parse(arrayForm, "plan: {plan};", Anonymous()).Should().Be("plan: null;"); + } + + [Fact] + public void EnvVarResolvedAtConstruction_NotReReadPerRequest() + { + const string name = "NPGSQLREST_TEST_CACHED"; + Environment.SetEnvironmentVariable(name, "first"); + try + { + var parser = Parser(envVars: new() { [name] = null }); + + var firstCall = Parse(parser, $"v: {{{name}}};", Anonymous()); + firstCall.Should().Be("v: \"first\";"); + + // mutate the process env AFTER construction + Environment.SetEnvironmentVariable(name, "second"); + + // value was captured at construction -> still "first", proving no per-request re-read + var secondCall = Parse(parser, $"v: {{{name}}};", Anonymous()); + secondCall.Should().Be("v: \"first\";"); + } + finally + { + Environment.SetEnvironmentVariable(name, null); + } + } +} diff --git a/NpgsqlRestTests/Setup/CompressionTestFixture.cs b/NpgsqlRestTests/Setup/CompressionTestFixture.cs index 2933add4..4c1c4d17 100644 --- a/NpgsqlRestTests/Setup/CompressionTestFixture.cs +++ b/NpgsqlRestTests/Setup/CompressionTestFixture.cs @@ -105,7 +105,8 @@ public CompressionTestFixture() authorizePaths: null, unauthorizedRedirectPath: null, unautorizedReturnToQueryParameter: null, - availableClaimTypes: null, + availableClaims: null, + availableEnvVars: null, logger: null); _app.UseMiddleware(); diff --git a/NpgsqlRestTests/Setup/StaticFilesTestFixture.cs b/NpgsqlRestTests/Setup/StaticFilesTestFixture.cs index f58bbbe1..31e8f6f9 100644 --- a/NpgsqlRestTests/Setup/StaticFilesTestFixture.cs +++ b/NpgsqlRestTests/Setup/StaticFilesTestFixture.cs @@ -101,7 +101,8 @@ public StaticFilesTestFixture() authorizePaths: ["/protected/*", "*.secret.html"], unauthorizedRedirectPath: "/login.html", unautorizedReturnToQueryParameter: "return_to", - availableClaimTypes: ["user_id", "user_name", "user_roles"], + availableClaims: new() { ["user_id"] = null, ["user_name"] = null, ["user_roles"] = null }, + availableEnvVars: null, logger: null); _app.UseMiddleware(); diff --git a/STATIC_CONTENT_ENV_VAR_INJECTION.md b/STATIC_CONTENT_ENV_VAR_INJECTION.md new file mode 100644 index 00000000..b13aadb2 --- /dev/null +++ b/STATIC_CONTENT_ENV_VAR_INJECTION.md @@ -0,0 +1,479 @@ +# Static-content env-var injection for `ParseContentOptions` + +> **Status**: design doc — not implemented. Carved out by a downstream +> consumer ([mathmodule2](https://github.com/vbilopav/mathmodule2)) who +> needs to surface a handful of K8s pod env vars to the SPA at boot time +> without rebuilding the bundle per environment. NpgsqlRest's existing +> `ParseContentOptions` already templates per-request user claims into +> static files via the same `{TOKEN}` substitution syntax; this proposes +> extending the same machinery to also feed *app-wide, request-independent* +> env-var values. + +## The downstream use case + +A Single-Page App bundled at build time can't read K8s env vars at +runtime. Standard solutions: + +1. **Server-rendered config injection** — server templates a small + `window.__appConfig = {...}` block into `index.html` per request, + reading from env. SPA reads `window.__appConfig` before mounting. + One round trip, app-wide values available before any code runs. +2. **Runtime `/config.json` endpoint** — SPA fetches config before + mounting. Two round trips, slightly more flexible. + +NpgsqlRest already implements **(1)** for per-user claims via +[`DefaultResponseParser.cs:13-78`](NpgsqlRestClient/DefaultResponseParser.cs#L13-L78) +and the `AvailableClaims` config key. So downstream consumers can do this +today for claim data: + +```html + + +``` + +```jsonc +"ParseContentOptions": { + "Enabled": true, + "FilePaths": [ "/index.html" ], + "AvailableClaims": ["user_id", "user_name"] +} +``` + +That works for per-user state but not for environment-level values like +`DEMO_FLAG`, `BUILD_LABEL`, analytics IDs, or feature-flag toggles — +NpgsqlRest's response parser today reads only from +`context.User.Claims`. + +The mechanism (token substitution via `Formatter.FormatString`) is +already wired; what's missing is a parallel feed for static, env-sourced +values. + +> **Relationship to the existing config-level env mechanism.** NpgsqlRest +> *already* injects env vars at the **configuration** layer: +> `Config:ParseEnvironmentVariables: true` (default) builds an `EnvDict` +> from `Environment.GetEnvironmentVariables()` and substitutes `{ENV}` +> tokens inside `appsettings.json` values (see +> [`Config.cs:102-110`](NpgsqlRestClient/Config.cs#L102-L110)). That feed +> is **server-side only** — the substituted values never leave the +> process. This proposal is a **different trust boundary**: it injects +> env values into bytes sent to *every browser client*. The two paths +> look similar in code (both call `Formatter.FormatString`) and must be +> kept distinct — see the security section. In particular, the +> config-level path can safely read the *whole* env dictionary; this +> client-facing path must **never** do that — it is allowlist-only. + +## What changes + +Add `AvailableEnvVars` under `StaticFiles.ParseContentOptions`. It accepts +**two forms**: + +- **Array form** — `["NAME1", "NAME2"]`: list the env-var names. A missing + env var resolves to the empty string `""`. +- **Object form** — `{ "NAME1": "default1", "NAME2": "" }`: list each name + with an explicit fallback value used when the env var is **absent** from + the process environment. + +Each name is resolved **once at parser construction** via +`Environment.GetEnvironmentVariable` and injected into the same +`replacements` dictionary the parser already hands to +`Formatter.FormatString`. The same `{NAME1}` placeholder syntax the +existing claim path uses now works for env vars too. + +**Values are JSON-escaped, exactly like claims.** The existing claim path +runs every value through `PgConverters.SerializeString` before +substitution, so `{user_id}` becomes the *quoted, escaped* JSON literal +`"123"` (or unquoted `null` when absent). Env vars follow the **same +rule**: the engine substitutes a fully-formed JSON literal, so the +template places the **bare** `{TOKEN}` with no surrounding quotes. This +is what makes the feature injection-safe — an env value containing `"` +or `` is escaped and cannot break out of the ` +``` + +K8s sets `BUILD_LABEL=demo`, `DEMO_FLAG=true`, `TRACKING_ID=GA-…` on the +pod; NpgsqlRest substitutes them into every served `index.html` without +the SPA bundle needing to know about them at build time. + +## Implementation + +Three files touched, one new behaviour, no breaking change. + +### 1. [`NpgsqlRestClient/App.cs`](NpgsqlRestClient/App.cs) (~5 lines) + +In `ConfigureStaticFiles`, add a read for the new key right next to the +existing `availableClaims`: + +```csharp +var availableClaims = _config.GetConfigEnumerable("AvailableClaims", parseCfg)?.ToArray(); +var availableEnvVars = _config.GetConfigEnumerable("AvailableEnvVars", parseCfg)?.ToArray(); +``` + +Thread `availableEnvVars` into the call to +`AppStaticFileMiddleware.ConfigureStaticFileMiddleware(...)` alongside +`availableClaims`. + +### 2. [`NpgsqlRestClient/AppStaticFileMiddleware.cs`](NpgsqlRestClient/AppStaticFileMiddleware.cs) (~3 lines) + +Add an `availableEnvVars` parameter to +`ConfigureStaticFileMiddleware` and thread it through into the +`DefaultResponseParser` constructor at the point that currently +passes `availableClaimTypes`. + +### 3. [`NpgsqlRestClient/DefaultResponseParser.cs`](NpgsqlRestClient/DefaultResponseParser.cs) + +Change the claim parameter to the name→default map and add the env-var +map. (`Dictionary`: value `null` = "no explicit default", +falling back to the per-feed default — `Consts.Null` for claims, `""` for +env vars.) + +```csharp +public class DefaultResponseParser( + NpgsqlRestAuthenticationOptions options, + string? antiforgeryFieldNameTag, + string? antiforgeryTokenTag, + Dictionary? availableClaims, + Dictionary? availableEnvVars) +{ + // Resolve env vars ONCE at construction. They don't change at + // runtime within the pod's lifetime, so re-reading per request + // would just add System.Environment overhead with no payoff. K8s + // pod restarts re-instantiate the middleware → re-read. + private readonly Dictionary _envVarReplacements = ResolveEnvVars(availableEnvVars); + + private static Dictionary ResolveEnvVars(Dictionary? envVars) + { + if (envVars is null || envVars.Count == 0) return []; + var result = new Dictionary(envVars.Count); + foreach (var (name, def) in envVars) + { + // present env wins → configured default → empty string. + var raw = Environment.GetEnvironmentVariable(name) ?? def ?? string.Empty; + // JSON-escape, exactly like the claim path. The substituted + // value is a complete JSON literal (a quoted string), so it + // is safe inside a ` is **not** neutralised and could still break out + of the surrounding ` +``` + +Behaviour details: + +- **Two forms.** `AvailableEnvVars` accepts an **array** of names (`["BUILD_LABEL"]`; a missing variable resolves to the empty string) or an **object** of name→default pairs (`{"DEMO_FLAG":"false"}`; the default is used when the variable is absent). `AvailableClaims` gains the same object form, so an absent claim can resolve to a configured default instead of `NULL`. +- **Resolved once at startup.** Env values are read at parser construction. A K8s pod restart re-reads them; changing a value in a running process is not picked up. +- **JSON-escaped, like claims.** Each value is substituted as a complete, escaped JSON literal, so templates use a bare `{NAME}` token (no surrounding quotes) and an accidental quote/backslash in a value cannot break the JS string. +- **Claims win on collision.** If a name exists both as a user claim and an env var, the per-request claim value takes precedence. + +### Security note + +Anything listed in `AvailableEnvVars` is templated into static content served to **any** client — treat it as a public allowlist. Never list a secret (database password, API key, signing token). Resolution is an explicit per-name lookup; the whole environment is never exposed. This is distinct from the server-side `Config:ParseEnvironmentVariables` mechanism, which substitutes `{ENV}` tokens into `appsettings.json` values that never leave the server. + +This is **fully backward compatible**: the new key is optional, and configs that omit it behave exactly as before. From 99c156c986d5c2cef72efa2ffc261ea734c48dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 3 Jun 2026 12:31:00 +0200 Subject: [PATCH 3/4] chore: drop STATIC_CONTENT_ENV_VAR_INJECTION.md design doc The design doc is no longer needed now that the feature has shipped in this branch; keep it out of the repo tree. --- STATIC_CONTENT_ENV_VAR_INJECTION.md | 479 ---------------------------- 1 file changed, 479 deletions(-) delete mode 100644 STATIC_CONTENT_ENV_VAR_INJECTION.md diff --git a/STATIC_CONTENT_ENV_VAR_INJECTION.md b/STATIC_CONTENT_ENV_VAR_INJECTION.md deleted file mode 100644 index b13aadb2..00000000 --- a/STATIC_CONTENT_ENV_VAR_INJECTION.md +++ /dev/null @@ -1,479 +0,0 @@ -# Static-content env-var injection for `ParseContentOptions` - -> **Status**: design doc — not implemented. Carved out by a downstream -> consumer ([mathmodule2](https://github.com/vbilopav/mathmodule2)) who -> needs to surface a handful of K8s pod env vars to the SPA at boot time -> without rebuilding the bundle per environment. NpgsqlRest's existing -> `ParseContentOptions` already templates per-request user claims into -> static files via the same `{TOKEN}` substitution syntax; this proposes -> extending the same machinery to also feed *app-wide, request-independent* -> env-var values. - -## The downstream use case - -A Single-Page App bundled at build time can't read K8s env vars at -runtime. Standard solutions: - -1. **Server-rendered config injection** — server templates a small - `window.__appConfig = {...}` block into `index.html` per request, - reading from env. SPA reads `window.__appConfig` before mounting. - One round trip, app-wide values available before any code runs. -2. **Runtime `/config.json` endpoint** — SPA fetches config before - mounting. Two round trips, slightly more flexible. - -NpgsqlRest already implements **(1)** for per-user claims via -[`DefaultResponseParser.cs:13-78`](NpgsqlRestClient/DefaultResponseParser.cs#L13-L78) -and the `AvailableClaims` config key. So downstream consumers can do this -today for claim data: - -```html - - -``` - -```jsonc -"ParseContentOptions": { - "Enabled": true, - "FilePaths": [ "/index.html" ], - "AvailableClaims": ["user_id", "user_name"] -} -``` - -That works for per-user state but not for environment-level values like -`DEMO_FLAG`, `BUILD_LABEL`, analytics IDs, or feature-flag toggles — -NpgsqlRest's response parser today reads only from -`context.User.Claims`. - -The mechanism (token substitution via `Formatter.FormatString`) is -already wired; what's missing is a parallel feed for static, env-sourced -values. - -> **Relationship to the existing config-level env mechanism.** NpgsqlRest -> *already* injects env vars at the **configuration** layer: -> `Config:ParseEnvironmentVariables: true` (default) builds an `EnvDict` -> from `Environment.GetEnvironmentVariables()` and substitutes `{ENV}` -> tokens inside `appsettings.json` values (see -> [`Config.cs:102-110`](NpgsqlRestClient/Config.cs#L102-L110)). That feed -> is **server-side only** — the substituted values never leave the -> process. This proposal is a **different trust boundary**: it injects -> env values into bytes sent to *every browser client*. The two paths -> look similar in code (both call `Formatter.FormatString`) and must be -> kept distinct — see the security section. In particular, the -> config-level path can safely read the *whole* env dictionary; this -> client-facing path must **never** do that — it is allowlist-only. - -## What changes - -Add `AvailableEnvVars` under `StaticFiles.ParseContentOptions`. It accepts -**two forms**: - -- **Array form** — `["NAME1", "NAME2"]`: list the env-var names. A missing - env var resolves to the empty string `""`. -- **Object form** — `{ "NAME1": "default1", "NAME2": "" }`: list each name - with an explicit fallback value used when the env var is **absent** from - the process environment. - -Each name is resolved **once at parser construction** via -`Environment.GetEnvironmentVariable` and injected into the same -`replacements` dictionary the parser already hands to -`Formatter.FormatString`. The same `{NAME1}` placeholder syntax the -existing claim path uses now works for env vars too. - -**Values are JSON-escaped, exactly like claims.** The existing claim path -runs every value through `PgConverters.SerializeString` before -substitution, so `{user_id}` becomes the *quoted, escaped* JSON literal -`"123"` (or unquoted `null` when absent). Env vars follow the **same -rule**: the engine substitutes a fully-formed JSON literal, so the -template places the **bare** `{TOKEN}` with no surrounding quotes. This -is what makes the feature injection-safe — an env value containing `"` -or `` is escaped and cannot break out of the ` -``` - -K8s sets `BUILD_LABEL=demo`, `DEMO_FLAG=true`, `TRACKING_ID=GA-…` on the -pod; NpgsqlRest substitutes them into every served `index.html` without -the SPA bundle needing to know about them at build time. - -## Implementation - -Three files touched, one new behaviour, no breaking change. - -### 1. [`NpgsqlRestClient/App.cs`](NpgsqlRestClient/App.cs) (~5 lines) - -In `ConfigureStaticFiles`, add a read for the new key right next to the -existing `availableClaims`: - -```csharp -var availableClaims = _config.GetConfigEnumerable("AvailableClaims", parseCfg)?.ToArray(); -var availableEnvVars = _config.GetConfigEnumerable("AvailableEnvVars", parseCfg)?.ToArray(); -``` - -Thread `availableEnvVars` into the call to -`AppStaticFileMiddleware.ConfigureStaticFileMiddleware(...)` alongside -`availableClaims`. - -### 2. [`NpgsqlRestClient/AppStaticFileMiddleware.cs`](NpgsqlRestClient/AppStaticFileMiddleware.cs) (~3 lines) - -Add an `availableEnvVars` parameter to -`ConfigureStaticFileMiddleware` and thread it through into the -`DefaultResponseParser` constructor at the point that currently -passes `availableClaimTypes`. - -### 3. [`NpgsqlRestClient/DefaultResponseParser.cs`](NpgsqlRestClient/DefaultResponseParser.cs) - -Change the claim parameter to the name→default map and add the env-var -map. (`Dictionary`: value `null` = "no explicit default", -falling back to the per-feed default — `Consts.Null` for claims, `""` for -env vars.) - -```csharp -public class DefaultResponseParser( - NpgsqlRestAuthenticationOptions options, - string? antiforgeryFieldNameTag, - string? antiforgeryTokenTag, - Dictionary? availableClaims, - Dictionary? availableEnvVars) -{ - // Resolve env vars ONCE at construction. They don't change at - // runtime within the pod's lifetime, so re-reading per request - // would just add System.Environment overhead with no payoff. K8s - // pod restarts re-instantiate the middleware → re-read. - private readonly Dictionary _envVarReplacements = ResolveEnvVars(availableEnvVars); - - private static Dictionary ResolveEnvVars(Dictionary? envVars) - { - if (envVars is null || envVars.Count == 0) return []; - var result = new Dictionary(envVars.Count); - foreach (var (name, def) in envVars) - { - // present env wins → configured default → empty string. - var raw = Environment.GetEnvironmentVariable(name) ?? def ?? string.Empty; - // JSON-escape, exactly like the claim path. The substituted - // value is a complete JSON literal (a quoted string), so it - // is safe inside a ` is **not** neutralised and could still break out - of the surrounding `