@@ -56,31 +56,26 @@ def __init__(self, root):
5656 self .root = Path (root ).resolve ()
5757 self .sources = {}
5858 self .stack = []
59+ self .reads = 0
5960
6061 def read (self , path ):
6162 path = Path (path ).resolve ()
6263 require (not any (c in str (path ) for c in "\t \r \n " ), "unsupported Nginx filename" )
6364 external_module = path .is_relative_to (Path ("/usr/share/nginx/modules-available" ))
6465 require (path .is_relative_to (self .root ) or external_module , "Nginx include escapes config directory" )
65- require (path not in self .stack and path not in self .sources ,
66- "repeated or cyclic Nginx include" )
66+ # A shared HTTP snippet can be included by several vhosts. Only an
67+ # active recursion is a cycle; each inclusion gets its own AST nodes.
68+ self .reads += 1
69+ require (path not in self .stack and self .reads <= 256 and len (self .stack ) < 32 ,
70+ "cyclic or excessive Nginx includes" )
6771 self .stack .append (path )
6872 source = path .read_bytes ().decode ("utf-8" )
69- require (" \\ " not in source , "escaped Nginx syntax needs manual review " )
73+ require (len ( source ) <= 1024 * 1024 , "Nginx source exceeds parser limit " )
7074 self .sources [path ] = source
71- pattern = r'''\s+|\#[^\n]*|"[^"\n]*"|'[^'\n]*'|[{};]|[^\s{};"'\#]+'''
72- tokens = []
73- end = 0
74- for match in re .finditer (pattern , source ):
75- require (match .start () == end )
76- end = match .end ()
77- token = match .group ()
78- if not token .isspace () and not token .startswith ("#" ):
79- tokens .append ((token , match .start ()))
80- require (end == len (source ))
75+ tokens = nginx_tokens (source )
8176 position = 0
8277
83- def parse (nested = False ):
78+ def parse (nested = False , map_values = False ):
8479 nonlocal position
8580 nodes = []
8681 while position < len (tokens ):
@@ -96,16 +91,20 @@ def parse(nested=False):
9691 args .append (token [1 :- 1 ] if token .startswith (('"' , "'" )) else token )
9792 position += 1
9893 require (args and position < len (tokens ))
94+ if not map_values :
95+ require (re .fullmatch (r"[a-zA-Z_][a-zA-Z_0-9]*" , args [0 ]),
96+ "escaped or unknown Nginx directive name" )
9997 terminator , close = tokens [position ]
10098 position += 1
10199 require (terminator != "}" )
102100 children = None
103101 if terminator == "{" :
104- children , close = parse (True )
102+ children , close = parse (True , args [ 0 ] == "map" )
105103 node = Node (args , children , path , start , close )
106104 nodes .append (node )
107105 if args [0 ] == "include" :
108- require (children is None and len (args ) == 2 and "$" not in args [1 ])
106+ require (children is None and len (args ) == 2
107+ and not any (c in args [1 ] for c in "$\\ " ))
109108 include = Path (args [1 ])
110109 if not include .is_absolute ():
111110 include = self .root / include
@@ -143,6 +142,64 @@ def exact(nodes, key):
143142 return [n for n in expand (nodes ) if n .args [0 ] == key ]
144143
145144
145+ def nginx_tokens (source ):
146+ """Track syntax boundaries without interpreting regex/escape expressions.
147+
148+ Preserve escaped pairs in token values: critical routing directives below
149+ accept only exact literals. Quotes, escaped delimiters and ${variables} in
150+ unrelated HTTP directives cannot manufacture a block or hide a listener.
151+ """
152+ tokens = []
153+ i = 0
154+ while i < len (source ):
155+ if source [i ].isspace ():
156+ i += 1
157+ continue
158+ if source [i ] == "#" :
159+ end = source .find ("\n " , i )
160+ i = len (source ) if end < 0 else end + 1
161+ continue
162+ start = i
163+ if source [i ] in "{};" :
164+ tokens .append ((source [i ], i ))
165+ i += 1
166+ continue
167+ chars = []
168+ quote = source [i ] if source [i ] in "\" '" else None
169+ if quote :
170+ chars .append (quote )
171+ i += 1
172+ closed = not quote
173+ while i < len (source ):
174+ c = source [i ]
175+ if c == "\\ " :
176+ require (i + 1 < len (source ), "incomplete Nginx escape" )
177+ chars .append (source [i :i + 2 ])
178+ i += 2
179+ elif quote and c == quote :
180+ chars .append (c )
181+ i += 1
182+ closed = True
183+ break
184+ elif not quote and source .startswith ("${" , i ):
185+ variable = re .match (r"\$\{[a-zA-Z_][a-zA-Z_0-9]*\}" , source [i :])
186+ require (variable is not None , "unsupported Nginx variable syntax" )
187+ chars .append (variable .group ())
188+ i += len (variable .group ())
189+ elif not quote and (c .isspace () or c in "{};#" ):
190+ break
191+ else :
192+ require (quote or c not in "\" '" , "mixed Nginx quote syntax" )
193+ chars .append (c )
194+ i += 1
195+ require (closed and chars , "unterminated Nginx token" )
196+ if quote :
197+ require (i == len (source ) or source [i ].isspace () or source [i ] in ";{}#)" ,
198+ "concatenated Nginx tokens" )
199+ tokens .append (("" .join (chars ), start ))
200+ return tokens
201+
202+
146203def nginx_plan (root , host , output ):
147204 domain (host )
148205 parser = Nginx (root )
@@ -158,19 +215,34 @@ def nginx_plan(root, host, output):
158215 and re .fullmatch (r"\$[a-zA-Z_][a-zA-Z_0-9]*" , mapping .args [2 ]))
159216 directives = [n .args for n in expand (router .children )]
160217 allowed = {"listen" , "proxy_pass" , "ssl_preread" , "proxy_protocol" , "proxy_timeout" ,
161- "proxy_connect_timeout" , "access_log" , "error_log" , "tcp_nodelay" }
162- require (all (d [0 ] in allowed for d in directives ))
218+ "proxy_connect_timeout" , "access_log" , "error_log" , "tcp_nodelay" ,
219+ "set_real_ip_from" }
220+ require (all (d [0 ] in allowed for d in directives )
221+ and all (n .children is None for n in expand (router .children )))
222+ trusted = [d for d in directives if d [0 ] == "set_real_ip_from" ]
223+ require (trusted in ([], [["set_real_ip_from" , "unix:" ]]),
224+ "unexpected stream PROXY trust boundary" )
163225 for required in (["ssl_preread" , "on" ], ["proxy_protocol" , "on" ],
164226 ["proxy_pass" , mapping .args [2 ]]):
165- require (directives . count ( required ) == 1 )
227+ require ([ d for d in directives if d [ 0 ] == required [ 0 ]] == [ required ] )
166228 listens = [d [1 :] for d in directives if d [0 ] == "listen" ]
167- require (listens == [["443" ]] or listens == [["0.0.0.0:443" ]])
229+ # Existing IPv6 ingress does not enable IPv6 Telemt egress or permit an AAAA
230+ # record for the new WEB hostname. Never edit these existing listen lines.
231+ require (len (listens ) in (1 , 2 ) and len ({tuple (x ) for x in listens }) == len (listens ))
232+ require (sum (x in (["443" ], ["0.0.0.0:443" ]) for x in listens ) == 1 )
233+ require (all (x in (["443" ], ["0.0.0.0:443" ], ["[::]:443" ]) for x in listens ))
168234 entries = list (expand (mapping .children ))
235+ hostname_flags = [n for n in entries if n .args [0 ] == "hostnames" ]
236+ require (not hostname_flags or (len (hostname_flags ) == 1 and entries [0 ] == hostname_flags [0 ]
237+ and hostname_flags [0 ].args == ["hostnames" ]
238+ and hostname_flags [0 ].children is None ))
239+ entries = [n for n in entries if n .args [0 ] != "hostnames" ]
169240 require (sum (n .args [0 ] == "default" for n in entries ) == 1 )
170241 require (len ({n .args [0 ] for n in entries }) == len (entries ))
171242 for n in entries :
172243 require (n .children is None and len (n .args ) == 2 )
173- require (n .args [0 ] == "default" or re .fullmatch (r"[a-z0-9.-]+" , n .args [0 ]))
244+ if n .args [0 ] != "default" :
245+ domain (n .args [0 ]) # Exact names only, even with the hostnames flag.
174246 require (re .fullmatch (r"[A-Za-z0-9_.:-]+" , n .args [1 ]))
175247 upstreams = exact (stream .children , "upstream" )
176248 owned = [n for n in upstreams if n .args == ["upstream" , "twm_frontend" ]]
@@ -187,7 +259,12 @@ def nginx_plan(root, host, output):
187259 if n .args [0 ] == "server_name" :
188260 require (host not in n .args [1 :])
189261 if n .args [0 ] == "listen" and n not in exact (router .children , "listen" ):
190- require (not any (re .search (r"(?:^|:)(443|7444|18080)$" , a ) for a in n .args [1 :]))
262+ require (len (n .args ) >= 2 )
263+ address = n .args [1 ]
264+ require (re .fullmatch (r"[0-9]+|[0-9.]+:[0-9]+|\[[0-9a-fA-F:]+\]:[0-9]+|unix:/[^\s\\$]+" , address ),
265+ "ambiguous Nginx listen address" )
266+ if not address .startswith ("unix:" ):
267+ require (int (address .rsplit (":" , 1 )[- 1 ]) not in (443 , 7444 , 18080 ))
191268 edits = {}
192269 if existing or owned or vhost .exists ():
193270 require (len (existing ) == len (owned ) == 1 and vhost .exists ())
0 commit comments