Skip to content

Commit 382376e

Browse files
committed
Add missing_resolver plugin for static DNS detection
Features: - Detects proxy_pass/fastcgi_pass/uwsgi_pass/scgi_pass/grpc_pass with static hostnames - Cloud provider detection (AWS ELB, GCP, Azure, Cloudflare, Heroku, etc.) → HIGH severity - Proper TLD classification with 100+ TLDs and compound TLD support (.co.uk, etc.) - Container orchestration awareness (Kubernetes, Docker, Consul, Mesos) - Checks for resolver directive when using variables - Analyzes upstream blocks for 'resolve' parameter - 15 test cases covering all scenarios Closes #63
1 parent f8461f6 commit 382376e

21 files changed

+731
-1
lines changed

docs/en/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Right now Gixy can find:
4747
* [[try_files_is_evil_too] try_files without open_file_cache](plugins/try_files_is_evil_too.md)
4848
* [[worker_rlimit_nofile_vs_connections] Worker connections vs rlimit](plugins/worker_rlimit_nofile_vs_connections.md)
4949
* [[low_keepalive_requests] Low keepalive_requests value](plugins/low_keepalive_requests.md)
50+
* [[missing_resolver] Static DNS resolution in proxy_pass](plugins/missing_resolver.md)
5051

5152
You can find things that Gixy is learning to detect at [Issues labeled with "new plugin"](https://github.com/dvershinin/gixy/issues?q=is%3Aissue+is%3Aopen+label%3A%22new+plugin%22)
5253

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# [missing_resolver] Static DNS Resolution in proxy_pass
2+
3+
Detects `proxy_pass` (and related) configurations where hostnames are resolved only at nginx startup, potentially causing requests to be sent to stale IP addresses.
4+
5+
## Why it matters
6+
7+
When you use a hostname directly in `proxy_pass`:
8+
9+
```nginx
10+
proxy_pass https://api.example.com;
11+
```
12+
13+
Nginx resolves the DNS **once at startup** and caches that IP forever. If the IP changes (common with cloud load balancers, CDNs, or failover scenarios), nginx will continue sending traffic to the old IP until restarted.
14+
15+
This can cause:
16+
- **Service outages** when backend IPs change
17+
- **Security issues** if old IPs are reassigned to malicious actors
18+
- **Load balancing failures** when using DNS-based load balancing
19+
20+
## Smart Detection Features
21+
22+
This plugin goes beyond simple suffix matching:
23+
24+
### 🔥 Cloud Provider Detection (HIGH severity)
25+
Automatically detects endpoints from major cloud providers where IPs change frequently:
26+
- **AWS**: ELB, CloudFront, API Gateway, Elastic Beanstalk, Lambda URLs, S3
27+
- **Google Cloud**: Cloud Run, Cloud Functions, App Engine, Google APIs
28+
- **Azure**: App Service, API Management, CDN, Traffic Manager, Blob Storage
29+
- **Cloudflare**: Workers, Pages
30+
- **PaaS**: Heroku, Vercel, Netlify, Railway, Render, Fly.io, DigitalOcean App Platform
31+
32+
### 🎯 Proper TLD Classification
33+
- Recognizes 100+ public TLDs (not just checking for a dot)
34+
- Supports compound TLDs: `.co.uk`, `.com.au`, `.co.jp`, etc.
35+
- Won't false-positive on internal hostnames
36+
37+
### 🐳 Container Orchestration Awareness
38+
Automatically skips internal service discovery patterns:
39+
- **Kubernetes**: `.svc.cluster.local`, `.pod.cluster.local`, `.default.svc`
40+
- **Docker**: `.docker.internal`
41+
- **Consul**: `.service.consul`, `.node.consul`
42+
- **Mesos**: `.marathon.mesos`
43+
- **Rancher**: `.rancher.internal`
44+
45+
### 🔍 Resolver Directive Checking
46+
Detects when you use a variable but forgot to configure the `resolver` directive:
47+
48+
```nginx
49+
# This WON'T re-resolve without a resolver directive!
50+
set $backend api.example.com;
51+
proxy_pass http://$backend; # ← Plugin will warn about missing resolver
52+
```
53+
54+
### 📦 Upstream Analysis
55+
Checks upstream blocks for servers without the `resolve` parameter:
56+
57+
```nginx
58+
upstream backend {
59+
server api.example.com; # ← No 'resolve' = static DNS
60+
}
61+
```
62+
63+
## What triggers this check
64+
65+
| Pattern | Severity | Example |
66+
|---------|----------|---------|
67+
| Cloud provider endpoints | **HIGH** | `proxy_pass https://my-app.herokuapp.com;` |
68+
| Public domain hostnames | MEDIUM | `proxy_pass https://api.example.com;` |
69+
| Variable without resolver | MEDIUM | `set $x host.com; proxy_pass http://$x;` |
70+
| Upstream without resolve | MEDIUM | `upstream { server host.com; }` |
71+
72+
## What doesn't trigger (false positives avoided)
73+
74+
- ✅ IP addresses (no DNS resolution needed)
75+
- ✅ Unix sockets (`unix:/path/to/socket`)
76+
- ✅ Internal domains (`.internal`, `.local`, `.lan`, `.corp`, `.home`, etc.)
77+
- ✅ Single-label hostnames (`proxy_pass http://backend;`)
78+
- ✅ Kubernetes services (`.svc.cluster.local`)
79+
- ✅ Consul services (`.service.consul`)
80+
- ✅ Docker internal (`.docker.internal`)
81+
- ✅ URLs with variables AND resolver configured
82+
- ✅ Upstream servers with `resolve` parameter
83+
84+
## Examples
85+
86+
### Bad: Cloud provider endpoint (HIGH severity)
87+
88+
```nginx
89+
# CRITICAL: AWS ELB IPs change constantly!
90+
location /api {
91+
proxy_pass https://my-app-123456789.us-east-1.elb.amazonaws.com;
92+
}
93+
```
94+
95+
### Bad: Static hostname (MEDIUM severity)
96+
97+
```nginx
98+
# DNS resolved once at startup
99+
location /api {
100+
proxy_pass https://api.example.com;
101+
}
102+
```
103+
104+
### Bad: Variable without resolver
105+
106+
```nginx
107+
# Variable alone doesn't enable re-resolution!
108+
set $backend api.example.com;
109+
proxy_pass http://$backend;
110+
```
111+
112+
### Bad: Upstream without resolve
113+
114+
```nginx
115+
upstream backend {
116+
server api.example.com:8080; # No resolve parameter
117+
}
118+
119+
server {
120+
location / {
121+
proxy_pass http://backend;
122+
}
123+
}
124+
```
125+
126+
### Good: Variable with resolver
127+
128+
```nginx
129+
resolver 8.8.8.8 valid=30s;
130+
131+
server {
132+
location /api {
133+
set $backend api.example.com;
134+
proxy_pass https://$backend;
135+
}
136+
}
137+
```
138+
139+
### Good: Upstream with resolve (nginx 1.27.3+)
140+
141+
```nginx
142+
resolver 8.8.8.8;
143+
144+
upstream backend {
145+
server api.example.com:8080 resolve;
146+
}
147+
148+
server {
149+
location / {
150+
proxy_pass http://backend;
151+
}
152+
}
153+
```
154+
155+
### Good: Internal service (auto-skipped)
156+
157+
```nginx
158+
# Kubernetes service - plugin knows this is internal
159+
proxy_pass http://api-service.default.svc.cluster.local;
160+
```
161+
162+
## Directives Checked
163+
164+
This plugin analyzes all proxy-related directives:
165+
- `proxy_pass`
166+
- `fastcgi_pass`
167+
- `uwsgi_pass`
168+
- `scgi_pass`
169+
- `grpc_pass`
170+
171+
## Configuration
172+
173+
Disable this plugin in `.gixy.yml`:
174+
175+
```yaml
176+
plugins:
177+
missing_resolver: false
178+
```
179+
180+
## References
181+
182+
- [nginx resolver directive](https://nginx.org/en/docs/http/ngx_http_core_module.html#resolver)
183+
- [nginx upstream server resolve parameter](https://nginx.org/en/docs/http/ngx_http_upstream_module.html#server)
184+
- [NGINX Blog: DNS Service Discovery](https://www.nginx.com/blog/dns-service-discovery-nginx-plus/)

gixy/directives/directive.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,17 @@ def find_single_directive_in_scope(self, name):
6767
return directive
6868
return None
6969

70-
70+
def find_imperative_directives_in_scope(self, name, ancestors=True):
71+
"""Find imperative directives (like upstream blocks) in the current scope.
72+
73+
Unlike find_directives_in_scope which looks for directives before this one,
74+
this method finds blocks/directives that can be referenced from anywhere
75+
(like upstream blocks which are defined at http level but used in server/location).
76+
"""
77+
for parent in self.parents:
78+
yield from parent.find(name, flat=False)
79+
if not ancestors:
80+
break
7181

7282
def __str__(self):
7383
return f"{self.name} {' '.join(self.args)};"
@@ -234,6 +244,28 @@ def __init__(self, name, args):
234244
self.path = args[0]
235245

236246

247+
def is_ipv6(host, strip_brackets=True):
248+
"""Check if a string is an IPv6 address (may include port)."""
249+
if strip_brackets and host.startswith("[") and "]" in host:
250+
host = host.split("]")[0][1:]
251+
try:
252+
ipaddress.IPv6Address(host)
253+
return True
254+
except ValueError:
255+
return False
256+
257+
258+
def is_ipv4(host, strip_port=True):
259+
"""Check if a string is an IPv4 address (may include port)."""
260+
if strip_port:
261+
host = host.rsplit(":", 1)[0]
262+
try:
263+
ipaddress.IPv4Address(host)
264+
return True
265+
except ValueError:
266+
return False
267+
268+
237269
def is_local_ipv6(ip):
238270
"""
239271
Check if an IPv6 address is a local address

0 commit comments

Comments
 (0)