diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 5bd465d5..7e16ec39 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.3 # Add workflow-level permissions permissions: 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/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj index 0c5e869d..b3a11edb 100644 --- a/NpgsqlRestClient/NpgsqlRestClient.csproj +++ b/NpgsqlRestClient/NpgsqlRestClient.csproj @@ -10,7 +10,7 @@ true true full - 3.16.2 + 3.16.3 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/changelog/v3.16.3.md b/changelog/v3.16.3.md new file mode 100644 index 00000000..5a9a640a --- /dev/null +++ b/changelog/v3.16.3.md @@ -0,0 +1,52 @@ +# Changelog v3.16.3 (2026-06-03) + +## Version [3.16.3](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.16.3) (2026-06-03) + +[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.16.2...3.16.3) + +Patch release that lets static-content parsing template **environment-variable values** into served files, alongside the existing per-user claim templating. This is aimed at Single-Page Apps deployed to Kubernetes: the SPA bundle is built once, and app-wide values (`BUILD_LABEL`, feature-flag toggles, analytics IDs) are injected from pod env vars at boot without rebuilding the bundle per environment. + +## What changed + +### `AvailableEnvVars` under `StaticFiles:ParseContentOptions` + +A new optional config key lists environment variable names whose values are templated into static content using the **same `{NAME}` tag syntax** the claim path already uses: + +```jsonc +"StaticFiles": { + "Enabled": true, + "ParseContentOptions": { + "Enabled": true, + "FilePaths": [ "/index.html" ], + "AvailableClaims": [ "user_id", "user_name" ], + "AvailableEnvVars": { + "BUILD_LABEL": "local", + "DEMO_FLAG": "false", + "TRACKING_ID": "" + } + } +} +``` + +```html + +``` + +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. diff --git a/npm/package.json b/npm/package.json index d2203c67..5b9f773e 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "npgsqlrest", - "version": "3.16.2", + "version": "3.16.3", "description": "Automatic REST API for PostgreSQL Databases Client Build", "scripts": { "postinstall": "node postinstall.js", diff --git a/npm/postinstall.js b/npm/postinstall.js index 60c4eccb..6d3316b9 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.3/"; // Download binary next to this script, not to ../.bin/ const binDir = path.join(__dirname, "bin");