Coverage for utilities/jinja2.py: 29%

69 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import fnmatch 

2import os 

3import re 

4 

5from django.apps import apps 

6from django.core.exceptions import ValidationError 

7from django.utils.translation import gettext_lazy as _ 

8from jinja2 import BaseLoader, TemplateNotFound 

9from jinja2.exceptions import TemplateSyntaxError 

10from jinja2.meta import find_referenced_templates 

11from jinja2.sandbox import SandboxedEnvironment 

12 

13from netbox.config import get_config 

14from netbox.registry import registry 

15 

16__all__ = ( 

17 'DEFAULT_JINJA2_FILTERS', 

18 'HTTP_HEADER_INVALID_CHARS_RE', 

19 'JINJA2_TEMPLATE_RE', 

20 'DataFileLoader', 

21 'env_filter', 

22 'render_jinja2', 

23 'sanitize_http_header', 

24 'validate_jinja2_syntax', 

25) 

26 

27# Control characters (C0 range plus DEL) which are invalid in an HTTP header value. Notably, this includes the 

28# carriage return and line feed characters used to smuggle additional headers (CR/LF injection). 

29HTTP_HEADER_INVALID_CHARS_RE = re.compile(r'[\x00-\x1f\x7f]') 

30 

31# Matches the start of a Jinja2 expression, statement, or comment ({{, {%, {#), to detect whether a 

32# template-capable field (e.g. Webhook.payload_url) is being used as a literal value or a template. 

33JINJA2_TEMPLATE_RE = re.compile(r'\{[{%#]') 

34 

35 

36def env_filter(name): 

37 """ 

38 Jinja2 filter which returns the value of an environment variable, provided its name matches one of the patterns 

39 listed in the JINJA_ENVIRONMENT_PARAMS configuration parameter. Patterns may include wildcards. Returns None if the 

40 variable is not defined or its name does not match an allowed pattern. 

41 """ 

42 patterns = get_config().JINJA_ENVIRONMENT_PARAMS or [] 

43 if not any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns): 

44 return None 

45 return os.environ.get(name) 

46 

47 

48def sanitize_http_header(value): 

49 """ 

50 Jinja2 filter which sanitizes a value for safe inclusion in a raw HTTP header by stripping newlines and other 

51 control characters. This guards against HTTP header (CR/LF) injection when interpolating untrusted data (e.g. 

52 user-controlled object attributes) into a webhook's additional headers. 

53 """ 

54 return HTTP_HEADER_INVALID_CHARS_RE.sub('', str(value)) 

55 

56 

57DEFAULT_JINJA2_FILTERS = { 

58 'env': env_filter, 

59} 

60 

61 

62class DataFileLoader(BaseLoader): 

63 """ 

64 Custom Jinja2 loader to facilitate populating template content from DataFiles. 

65 """ 

66 def __init__(self, data_source): 

67 self.data_source = data_source 

68 self._template_cache = {} 

69 

70 def get_source(self, environment, template): 

71 DataFile = apps.get_model('core', 'DataFile') 

72 

73 # Retrieve template content from cache 

74 try: 

75 template_source = self._template_cache[template] 

76 except KeyError: 

77 raise TemplateNotFound(template) 

78 

79 # Find and pre-fetch referenced templates 

80 if referenced_templates := tuple(find_referenced_templates(environment.parse(template_source))): 

81 related_files = DataFile.objects.filter(source=self.data_source) 

82 # None indicates the use of dynamic resolution. If dependent files are statically 

83 # defined, we can filter by path for optimization. 

84 if None not in referenced_templates: 

85 related_files = related_files.filter(path__in=referenced_templates) 

86 self.cache_templates({ 

87 df.path: df.data_as_string for df in related_files 

88 }) 

89 

90 return template_source, template, lambda: True 

91 

92 def cache_templates(self, templates): 

93 self._template_cache.update(templates) 

94 

95 

96# 

97# Utility functions 

98# 

99 

100def _jinja2_filters(filters=None): 

101 """ 

102 Build the Jinja2 filter table: default < plugin-registered < instance JINJA_FILTERS < filters 

103 passed for this call, in increasing precedence. Instance-level config wins over 

104 plugin-registered filters so site admins can override anything. Filters passed for this call 

105 take precedence over all of them, so that context-specific (e.g. sanitization) filters cannot 

106 be shadowed. Shared by render_jinja2() and validate_jinja2_syntax() so both see an identical 

107 filter table. 

108 """ 

109 return { 

110 **DEFAULT_JINJA2_FILTERS, 

111 **registry['plugins'].get('jinja_filters', {}), 

112 **get_config().JINJA_FILTERS, 

113 **(filters or {}), 

114 } 

115 

116 

117def render_jinja2(template_code, context, environment_params=None, data_file=None, debug=False, filters=None): 

118 """ 

119 Render a Jinja2 template with the provided context. Return the rendered content. 

120 

121 If debug is True, the Jinja2 debug extension is enabled to assist with template development. 

122 

123 The optional `filters` argument is a mapping of additional Jinja2 filters to make available for this render only 

124 (e.g. context-specific sanitization filters). These take precedence over the default and user-configured filters. 

125 """ 

126 environment_params = dict(environment_params or {}) 

127 

128 if debug: 

129 extensions = list(environment_params.get('extensions', [])) 

130 if 'jinja2.ext.debug' not in extensions: 

131 extensions.append('jinja2.ext.debug') 

132 environment_params['extensions'] = extensions 

133 

134 if 'loader' not in environment_params: 

135 if data_file: 

136 loader = DataFileLoader(data_file.source) 

137 loader.cache_templates({ 

138 data_file.path: template_code 

139 }) 

140 else: 

141 loader = BaseLoader() 

142 environment_params['loader'] = loader 

143 

144 environment = SandboxedEnvironment(**environment_params) 

145 environment.filters.update(_jinja2_filters(filters)) 

146 

147 if data_file: 

148 template = environment.get_template(data_file.path) 

149 else: 

150 template = environment.from_string(source=template_code) 

151 return template.render(**context) 

152 

153 

154def validate_jinja2_syntax(template_code, filters=None): 

155 """ 

156 Validate that template_code is syntactically well-formed Jinja2 -- including that any filters 

157 it references are registered -- without rendering it, so no context data is required. Pass the 

158 same `filters` used at render time (see render_jinja2()) for an identical filter table. Raises 

159 django.core.exceptions.ValidationError on failure. 

160 """ 

161 environment = SandboxedEnvironment(loader=BaseLoader()) 

162 environment.filters.update(_jinja2_filters(filters)) 

163 try: 

164 environment.compile(template_code) 

165 except TemplateSyntaxError as e: 

166 raise ValidationError(_("Invalid template: {error}").format(error=e))