From 4d8dbc6ffe3cc481da2f51bdde73b0a527f60495 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 May 2026 14:47:04 -0400 Subject: [PATCH] Closes #22212: Support for exposing environment parameters in Jinja template context (#22289) --- docs/configuration/system.md | 30 ++++++++++++++++++++ netbox/extras/tests/test_models.py | 44 +++++++++++++++++++++++++++++- netbox/netbox/settings.py | 1 + netbox/utilities/jinja2.py | 28 ++++++++++++++++++- 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/docs/configuration/system.md b/docs/configuration/system.md index f6770d50c..6951e911d 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -145,6 +145,24 @@ Set this configuration parameter to `True` for NetBox deployments which do not h --- +## JINJA_ENVIRONMENT_PARAMS + +Default: `[]` + +A list of system environment variable names which may be referenced from within Jinja2 templates via the built-in [`env`](#jinja2_filters) filter. Patterns may include wildcards (matched using Python's `fnmatch` syntax). Any variable whose name does not match an entry in this list cannot be referenced from a template. For example: + +```python +JINJA_ENVIRONMENT_PARAMS = [ + 'WEBHOOK_TOKEN_*', + 'DEFAULT_SECRET_ID', +] +``` + +!!! info "Parameter names are case-sensitive" + For example, `FOO_*` will match `FOO_BAR` but `foo_*` will not. + +--- + ## JINJA2_FILTERS Default: `{}` @@ -160,6 +178,18 @@ JINJA2_FILTERS = { } ``` +NetBox also registers the following filters by default. Any entry defined in `JINJA2_FILTERS` with the same name will override the default. + +| Filter | Description | +|---|---| +| `env` | Returns the value of the system environment variable with the given name, provided its name matches an entry in [`JINJA_ENVIRONMENT_PARAMS`](#jinja_environment_params). Returns `None` if the variable is not defined or its name is not whitelisted. | + +For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja2 template may reference an environment variable as: + +``` +Authorization: Bearer {{ 'WEBHOOK_TOKEN_3' | env }} +``` + --- ## LOGGING diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 2c43eb927..d827c641b 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -31,7 +31,7 @@ from extras.models import ( from extras.models.mixins import RenderTemplateMixin from tenancy.models import Tenant, TenantGroup from utilities.exceptions import AbortRequest -from utilities.jinja2 import render_jinja2 +from utilities.jinja2 import env_filter, render_jinja2 from utilities.tables import get_table_for_model from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -973,6 +973,48 @@ class ConfigTemplateDebugTestCase(TestCase): render_jinja2("{% debug %}", {}, debug=False) +class JinjaEnvFilterTestCase(TestCase): + """ + Tests for the env() Jinja2 filter and the JINJA_ENVIRONMENT_PARAMS configuration parameter. + """ + + def test_env_filter_returns_value_for_matching_name(self): + with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \ + self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']): + self.assertEqual(env_filter('NETBOX_TEST_TOKEN'), 'secret') + + def test_env_filter_returns_none_for_unmatched_name(self): + with patch.dict('os.environ', {'NETBOX_OTHER_TOKEN': 'secret'}, clear=False), \ + self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']): + self.assertIsNone(env_filter('NETBOX_OTHER_TOKEN')) + + def test_env_filter_wildcard_match(self): + with patch.dict('os.environ', {'NETBOX_TEST_TOKEN_1': 'one', 'NETBOX_TEST_TOKEN_2': 'two'}, clear=False), \ + self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN_*']): + self.assertEqual(env_filter('NETBOX_TEST_TOKEN_1'), 'one') + self.assertEqual(env_filter('NETBOX_TEST_TOKEN_2'), 'two') + + def test_env_filter_returns_none_for_missing_env_var(self): + with self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_MISSING_VAR']): + self.assertIsNone(env_filter('NETBOX_MISSING_VAR')) + + def test_env_filter_empty_whitelist_returns_none(self): + with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \ + self.settings(JINJA_ENVIRONMENT_PARAMS=[]): + self.assertIsNone(env_filter('NETBOX_TEST_TOKEN')) + + def test_env_filter_registered_by_default(self): + with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \ + self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']): + output = render_jinja2("{{ 'NETBOX_TEST_TOKEN' | env }}", {}) + self.assertEqual(output, 'secret') + + def test_user_defined_filter_overrides_default(self): + with self.settings(JINJA2_FILTERS={'env': lambda name: 'overridden'}): + output = render_jinja2("{{ 'NETBOX_TEST_TOKEN' | env }}", {}) + self.assertEqual(output, 'overridden') + + class ExportTemplateContextTestCase(TestCase): """ Tests for ExportTemplate.get_context() including public model population. diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2f72d70e6..11afc1d16 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -140,6 +140,7 @@ HTTP_CLIENT_IP_HEADERS = getattr(configuration, 'HTTP_CLIENT_IP_HEADERS', ( HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {}) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False) +JINJA_ENVIRONMENT_PARAMS = getattr(configuration, 'JINJA_ENVIRONMENT_PARAMS', []) JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {}) LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us') LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH diff --git a/netbox/utilities/jinja2.py b/netbox/utilities/jinja2.py index 3926ebbf1..4263c69e2 100644 --- a/netbox/utilities/jinja2.py +++ b/netbox/utilities/jinja2.py @@ -1,3 +1,6 @@ +import fnmatch +import os + from django.apps import apps from jinja2 import BaseLoader, TemplateNotFound from jinja2.meta import find_referenced_templates @@ -6,11 +9,30 @@ from jinja2.sandbox import SandboxedEnvironment from netbox.config import get_config __all__ = ( + 'DEFAULT_JINJA2_FILTERS', 'DataFileLoader', + 'env_filter', 'render_jinja2', ) +def env_filter(name): + """ + Jinja2 filter which returns the value of an environment variable, provided its name matches one of the patterns + listed in the JINJA_ENVIRONMENT_PARAMS configuration parameter. Patterns may include wildcards. Returns None if the + variable is not defined or its name does not match an allowed pattern. + """ + patterns = get_config().JINJA_ENVIRONMENT_PARAMS or [] + if not any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns): + return None + return os.environ.get(name) + + +DEFAULT_JINJA2_FILTERS = { + 'env': env_filter, +} + + class DataFileLoader(BaseLoader): """ Custom Jinja2 loader to facilitate populating template content from DataFiles. @@ -74,7 +96,11 @@ def render_jinja2(template_code, context, environment_params=None, data_file=Non environment_params['loader'] = loader environment = SandboxedEnvironment(**environment_params) - environment.filters.update(get_config().JINJA2_FILTERS) + + # Register default filters, then apply any user-defined filters. User-defined entries take precedence so that + # existing JINJA2_FILTERS configurations are never overridden. + filters = {**DEFAULT_JINJA2_FILTERS, **get_config().JINJA2_FILTERS} + environment.filters.update(filters) if data_file: template = environment.get_template(data_file.path)