Skip to Content
OpenVAPTAlpha
Pre-release. Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. Free for your own use, 100% at your own risk. This website collects no data. Read MoreRead Less
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
MediumNET-APP-002Network ExposureNetwork

Application Server Ports Reachable Without a Reverse Proxy

Checks whether application servers and developer tools (Node.js, Tomcat, Django, Flask, Vite) serve HTTP on network-reachable addresses.

Why It Matters
Application servers are designed to sit behind a reverse proxy that adds TLS, security headers, logging and request limits. Reached directly they bypass all of that, and development servers on these ports often run with debug pages on. The port number alone does not prove a weakness, so this is rated Medium and should be reviewed.
How It Detects
Matches network-reachable listening TCP sockets from /proc/net against the default HTTP ports of common application servers and developer tools, recording how each is bound and whether a web server listens on port 80 or 443 on this machine (which suggests the application was meant to be proxied). No packets are sent.
How to Fix
Bind the application to 127.0.0.1 (Node.js: app.listen(3000, '127.0.0.1'); Gunicorn: --bind 127.0.0.1:8000; Tomcat: address="127.0.0.1" on the Connector) and publish it only through nginx or Apache.
How to Verify
ss -tlnp shows these ports on 127.0.0.1 or ::1 only.
CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:L
Control Category
Network Exposure: CIS Controls v8 4, 12, 13; NIST CSF 2.0 PR.IR, PR.PS
Fix Effort
Low
CIS Controls v8 Safeguard
4.8
NIST CSF 2.0 Subcategory
PR.PS-01, PR.IR-01
CWE
CWE-1188, CWE-1327
OWASP Top 10
A05:2021
NIST SP 800-53 Rev. 5
SC-7, CM-7
Benchmark Topic
Expose applications only through a hardened front end (topic only, no benchmark text)
References
OWASP Node.js Security Cheat Sheet
Gunicorn documentation, Deploying Gunicorn (behind a proxy)
Learn the theory behind this check: P13 Application Servers: Tomcat, Node.js and Python.

Source Code

The complete file that defines this check. Run it with openvapt scan --only NET-APP-002, or read it offline with openvapt explain NET-APP-002.

internal/checks/network/firewall_exposure.go · Lines 1-296View on GitHub at This Commit
1package network
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/openvapt/openvapt/internal/check"
9 "github.com/openvapt/openvapt/internal/checks/collect"
10 "github.com/openvapt/openvapt/internal/sys"
11)
12
13// Checks that compare what the host firewall allows with what is actually
14// listening. A firewall that is "on" can still let a database through, keep
15// holes open for services long gone, or be bypassed by Docker.
16
17// codePorts are debuggers, management protocols and shutdown ports of
18// application servers that give code execution or control to whoever
19// connects. Sources: Node.js, Apache Tomcat, PHP-FPM and Jupyter
20// documentation.
21var codePorts = map[int]string{
22 9229: "Node.js inspector (debugger)", 8009: "Tomcat AJP connector", 8005: "Tomcat shutdown port",
23 9000: "PHP-FPM FastCGI", 8888: "Jupyter Notebook",
24}
25
26// appPorts are the default HTTP ports of application servers and developer
27// tools that are meant to sit behind a reverse proxy, never face the
28// network. Reaching one directly bypasses TLS, headers and limits, but the
29// port number alone does not prove code execution, so the severity is lower
30// than codePorts. Sources: Node.js, Django, Flask, Vite and Tomcat docs.
31var appPorts = map[int]string{
32 3000: "Node.js / Express app server", 3001: "Node.js app server", 4000: "app server", 4200: "Angular dev server",
33 5000: "Flask / Python dev server", 5173: "Vite dev server", 8000: "Django / Gunicorn / Uvicorn", 8008: "app server",
34 8080: "Tomcat / Java HTTP connector", 8081: "app server", 8088: "app server",
35}
36
37// sensitivePort names a data, administrative or application server port.
38func sensitivePort(p int) (string, bool) {
39 for _, m := range []map[int]string{dataPorts, controlPorts, managementPorts, codePorts, appPorts, aiPorts} {
40 if n, ok := m[p]; ok {
41 return n, true
42 }
43 }
44 return "", false
45}
46
47var refNIST41 = check.Ref{Title: "NIST SP 800-41 Rev. 1, Guidelines on Firewalls and Firewall Policy", URL: "https://csrc.nist.gov/pubs/sp/800/41/r1/final"}
48
49func publicListeners(s sys.System) ([]collect.Listener, []check.Result) {
50 ls, err := collect.Listeners(s)
51 if err != nil {
52 return nil, []check.Result{check.IncompleteR("/proc/net", "One or more socket tables were unreadable or partial")}
53 }
54 var pub []collect.Listener
55 for _, l := range ls {
56 if l.Public() {
57 pub = append(pub, l)
58 }
59 }
60 return pub, nil
61}
62
63func init() {
64 check.Register(&check.Check{
65 ID: "NET-FW-003", Title: "Firewall Allows Sensitive Services From Anywhere", Domain: check.DomainNetwork,
66 Severity: check.High, Category: check.CatNetwork, Effort: check.EffortLow,
67 CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H",
68 Summary: "Checks whether the host firewall explicitly opens a listening database, administrative or application server port to every address on the internet.",
69 Rationale: "A default-deny firewall only protects what it does not allow. A single \"allow 3306 from anywhere\" rule, often added while debugging, makes a database or admin panel as exposed as if there were no firewall at all.",
70 Detection: "Reads the incoming allow rules of ufw (ufw status verbose), nftables (nft list ruleset, input-hook chains) or iptables (iptables -S, INPUT and ufw chains), and matches rules with no source restriction against network-reachable listening sockets on well-known data, administrative and application server ports.",
71 Fix: "Delete the rule or restrict it to the addresses that need it, for example: ufw delete allow 3306 && ufw allow from 203.0.113.10 to any port 3306 proto tcp . Better, bind the service to 127.0.0.1 and reach it through an SSH tunnel or VPN.",
72 Verify: "ufw status numbered or nft list ruleset shows no rule opening that port to Anywhere.",
73 Refs: []check.Ref{refNIST41, {Title: "ufw(8) manual (Ubuntu)", URL: "https://manpages.ubuntu.com/manpages/noble/man8/ufw.8.html"}},
74 Standards: check.Standards{CWE: []string{"CWE-923", "CWE-1327"}, OWASP: []string{"A05:2021"}, NIST: []string{"SC-7", "AC-4", "CM-7"}, CISTopic: "Restrict firewall rules to required sources"},
75 Run: func(ctx context.Context, s sys.System) []check.Result {
76 fw := collect.ReadFirewall(ctx, s)
77 if len(fw.Rules) == 0 {
78 return []check.Result{check.SkipR("firewall", "No firewall allow rules found (see NET-FW-001 for whether a firewall is active)")}
79 }
80 pub, inc := publicListeners(s)
81 if inc != nil {
82 return inc
83 }
84 var bad []string
85 for _, l := range pub {
86 name, ok := sensitivePort(l.Port)
87 if !ok {
88 continue
89 }
90 for _, r := range fw.Rules {
91 if r.Any() && r.Covers(l.Proto, l.Port) {
92 bad = append(bad, fmt.Sprintf("%s (%s) allowed from anywhere by %s rule: %s", l.String(), name, r.Engine, r.Raw))
93 break
94 }
95 }
96 }
97 if len(bad) > 0 {
98 return []check.Result{check.FailR("firewall", "Sensitive services are open to every address", bad...)}
99 }
100 return []check.Result{check.PassR("firewall", "No sensitive listening service is opened to every address")}
101 },
102 })
103
104 check.Register(&check.Check{
105 ID: "NET-FW-004", Title: "Firewall Rules Open Ports With No Service", Domain: check.DomainNetwork,
106 Severity: check.Low, Category: check.CatNetwork, Effort: check.EffortLow,
107 CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N",
108 Summary: "Checks for firewall allow rules whose port has no listening service on this machine.",
109 Rationale: "Stale rules are holes waiting for a service. Anything later started on that port, by an administrator, a package or an attacker, becomes reachable without anyone deciding it should be. Rule sets also become impossible to review when they no longer match reality.",
110 Detection: "Compares each single-port incoming allow rule from ufw, nftables or iptables with the listening sockets in /proc/net (tcp, tcp6, udp, udp6). Port ranges are not judged, because they are often used for passive FTP or media and may be idle.",
111 Fix: "Remove rules for services that no longer exist (ufw status numbered, then ufw delete <n>), or note why a rule must stay. Review the firewall whenever a service is retired.",
112 Verify: "Re-run this check; every allowed port should have a listening service.",
113 Refs: []check.Ref{refNIST41},
114 Standards: check.Standards{CWE: []string{"CWE-1188"}, NIST: []string{"SC-7", "CM-7", "CM-3"}, CISTopic: "Review firewall rules periodically"},
115 Run: func(ctx context.Context, s sys.System) []check.Result {
116 fw := collect.ReadFirewall(ctx, s)
117 if len(fw.Rules) == 0 {
118 return []check.Result{check.SkipR("firewall", "No firewall allow rules found")}
119 }
120 ls, err := collect.Listeners(s)
121 if err != nil {
122 return []check.Result{check.IncompleteR("/proc/net", "One or more socket tables were unreadable or partial")}
123 }
124 seen := map[string]bool{}
125 var bad []string
126 for _, r := range fw.Rules {
127 if r.From != r.To {
128 continue
129 }
130 used := false
131 for _, l := range ls {
132 if r.Covers(l.Proto, l.Port) {
133 used = true
134 break
135 }
136 }
137 key := fmt.Sprintf("%s/%d", r.Proto, r.From)
138 if !used && !seen[key] {
139 seen[key] = true
140 bad = append(bad, fmt.Sprintf("port %d%s allowed by %s but nothing listens on it: %s", r.From, protoSuffix(r.Proto), r.Engine, r.Raw))
141 }
142 }
143 if len(bad) > 0 {
144 return []check.Result{check.FailR("firewall", fmt.Sprintf("%d allow rule(s) open ports with no service", len(bad)), bad...)}
145 }
146 return []check.Result{check.PassR("firewall", "Every single-port allow rule matches a listening service")}
147 },
148 })
149
150 check.Register(&check.Check{
151 ID: "NET-FW-005", Title: "Container Ports Bypass the Host Firewall", Domain: check.DomainNetwork,
152 Severity: check.Medium, Category: check.CatNetwork, Effort: check.EffortMedium,
153 CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:L",
154 Summary: "Checks whether Docker publishes container ports through its own iptables chains with no DOCKER-USER filtering.",
155 Rationale: "Docker inserts its NAT and FORWARD rules ahead of ufw and of the INPUT chain, so a port published with -p 8080:80 is reachable from the internet even when ufw says the port is denied. Administrators routinely believe the firewall protects these containers when it does not.",
156 Detection: "Reads iptables -S and looks for ACCEPT rules with a destination port in the DOCKER chain (published container ports). Fails when there are such rules and the DOCKER-USER chain contains no DROP or REJECT rule.",
157 Fix: "Publish container ports on loopback only (-p 127.0.0.1:8080:80) and put a reverse proxy in front, or add filtering to DOCKER-USER, for example: iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -j DROP (persist it with your firewall tooling). Caution: test from an allowed address first; a wrong interface name or source range cuts access to every published container.",
158 Verify: "docker ps --format '{{.Ports}}' shows 127.0.0.1 bindings, or iptables -S DOCKER-USER shows DROP rules.",
159 Refs: []check.Ref{{Title: "Docker documentation, Packet filtering and firewalls", URL: "https://docs.docker.com/engine/network/packet-filtering-firewalls/"}, refNIST41},
160 Standards: check.Standards{CWE: []string{"CWE-923", "CWE-1327"}, OWASP: []string{"A05:2021"}, NIST: []string{"SC-7", "CM-7"}, CISTopic: "Filter container traffic"},
161 Run: func(ctx context.Context, s sys.System) []check.Result {
162 fw := collect.ReadFirewall(ctx, s)
163 if len(fw.Docker) == 0 {
164 return []check.Result{check.PassR("iptables", "No published container ports found in the Docker chain")}
165 }
166 if fw.DockerUser {
167 return []check.Result{check.PassR("iptables", "Published container ports are filtered in DOCKER-USER")}
168 }
169 return []check.Result{check.FailR("iptables", fmt.Sprintf("%d published container port rule(s) are not filtered by the host firewall", len(fw.Docker)), fw.Docker...)}
170 },
171 })
172
173 check.Register(&check.Check{
174 ID: "NET-FW-006", Title: "Firewall Exposure Map of Listening Services", Domain: check.DomainNetwork,
175 Severity: check.Info, Category: check.CatNetwork, Effort: check.EffortLow,
176 CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N",
177 Summary: "Shows, for every network-reachable listening socket, what the host firewall does with traffic to it.",
178 Rationale: "Exposure is the combination of what listens and what the firewall lets through. Seeing both side by side is how assessors find services that are open by accident and confirm that the rest are blocked on purpose.",
179 Detection: "Combines the listening sockets in /proc/net with the firewall's default policy and allow rules (ufw, nftables or iptables). Each socket is labelled allowed from anywhere, allowed from restricted sources, blocked by default deny, or reachable because no filtering firewall is active.",
180 Fix: "For each socket labelled allowed from anywhere or reachable, decide whether it must be public. If not, bind it to 127.0.0.1 or restrict the firewall rule.",
181 Verify: "Re-run this check after changes and compare the map.",
182 Refs: []check.Ref{refNIST41, {Title: "NIST SP 800-115, Technical Guide to Information Security Testing and Assessment", URL: "https://csrc.nist.gov/pubs/sp/800/115/final"}},
183 Standards: check.Standards{CWE: []string{"CWE-923"}, NIST: []string{"CM-7", "CM-8", "SC-7"}, CISTopic: "Network services inventory"},
184 Run: func(ctx context.Context, s sys.System) []check.Result {
185 pub, inc := publicListeners(s)
186 if inc != nil {
187 return inc
188 }
189 fw := collect.ReadFirewall(ctx, s)
190 engine := fw.Engine
191 if engine == "" {
192 engine = "none detected"
193 }
194 ev := []string{fmt.Sprintf("firewall: %s, default deny: %v", engine, fw.DefaultDeny)}
195 open := 0
196 for _, l := range pub {
197 state := "reachable (no default-deny firewall)"
198 if fw.DefaultDeny {
199 state = "blocked by default deny"
200 }
201 for _, r := range fw.Rules {
202 if r.Covers(l.Proto, l.Port) {
203 if r.Any() {
204 state = "allowed from anywhere"
205 } else {
206 state = "allowed from " + r.Source
207 }
208 break
209 }
210 }
211 if strings.HasPrefix(state, "allowed from anywhere") || strings.HasPrefix(state, "reachable") {
212 open++
213 }
214 ev = append(ev, l.String()+portName(l.Port)+": "+state)
215 }
216 return []check.Result{check.PassR("firewall", fmt.Sprintf("%d network-reachable sockets, %d open to every address", len(pub), open), ev...)}
217 },
218 })
219
220 check.Register(&check.Check{
221 ID: "NET-APP-001", Title: "Debugger or Application Control Ports Exposed", Domain: check.DomainNetwork,
222 Severity: check.High, Category: check.CatNetwork, Effort: check.EffortLow,
223 CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:L",
224 Summary: "Checks whether the Node.js inspector, Tomcat AJP or shutdown port, PHP-FPM FastCGI or Jupyter listen on network-reachable addresses.",
225 Rationale: "These ports are not web pages: the Node.js inspector and Jupyter run code for whoever connects, Tomcat AJP has been abused for file read and code execution (Ghostcat), the shutdown port stops the server, and PHP-FPM executes scripts handed to it. None of them is designed to face a network.",
226 Detection: "Matches network-reachable listening TCP sockets from /proc/net against the default ports of these debuggers and control protocols, recording how each is bound. Plain application HTTP ports are rated separately by NET-APP-002. No packets are sent.",
227 Fix: "Bind each to 127.0.0.1 or a Unix socket (node --inspect=127.0.0.1:9229; Tomcat Connector address=\"127.0.0.1\" or remove the AJP connector; PHP-FPM listen = /run/php/php-fpm.sock; jupyter --ip=127.0.0.1) and never expose a debugger.",
228 Verify: "ss -tlnp shows these ports on 127.0.0.1 or ::1 only.",
229 Refs: []check.Ref{{Title: "Node.js, Inspector security implications", URL: "https://nodejs.org/en/learn/getting-started/debugging#security-implications"},
230 {Title: "Apache Tomcat 9, AJP Connector", URL: "https://tomcat.apache.org/tomcat-9.0-doc/config/ajp.html"}},
231 Standards: check.Standards{CWE: []string{"CWE-489", "CWE-1327"}, OWASP: []string{"A05:2021"}, NIST: []string{"SC-7", "CM-7"}, CISTopic: "Expose applications only through a hardened front end"},
232 Run: appPortRun(codePorts, "Debugger or application control ports are network-reachable", "No debugger or application control port is network-reachable"),
233 })
234
235 check.Register(&check.Check{
236 ID: "NET-APP-002", Title: "Application Server Ports Reachable Without a Reverse Proxy", Domain: check.DomainNetwork,
237 Severity: check.Medium, Category: check.CatNetwork, Effort: check.EffortLow,
238 CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:L",
239 Summary: "Checks whether application servers and developer tools (Node.js, Tomcat, Django, Flask, Vite) serve HTTP on network-reachable addresses.",
240 Rationale: "Application servers are designed to sit behind a reverse proxy that adds TLS, security headers, logging and request limits. Reached directly they bypass all of that, and development servers on these ports often run with debug pages on. The port number alone does not prove a weakness, so this is rated Medium and should be reviewed.",
241 Detection: "Matches network-reachable listening TCP sockets from /proc/net against the default HTTP ports of common application servers and developer tools, recording how each is bound and whether a web server listens on port 80 or 443 on this machine (which suggests the application was meant to be proxied). No packets are sent.",
242 Fix: "Bind the application to 127.0.0.1 (Node.js: app.listen(3000, '127.0.0.1'); Gunicorn: --bind 127.0.0.1:8000; Tomcat: address=\"127.0.0.1\" on the Connector) and publish it only through nginx or Apache.",
243 Verify: "ss -tlnp shows these ports on 127.0.0.1 or ::1 only.",
244 Refs: []check.Ref{{Title: "OWASP Node.js Security Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html"},
245 {Title: "Gunicorn documentation, Deploying Gunicorn (behind a proxy)", URL: "https://gunicorn.org/deploy/"}},
246 Standards: check.Standards{CWE: []string{"CWE-1188", "CWE-1327"}, OWASP: []string{"A05:2021"}, NIST: []string{"SC-7", "CM-7"}, CISTopic: "Expose applications only through a hardened front end"},
247 Run: appPortRun(appPorts, "Application server ports are network-reachable", "No application server port is network-reachable"),
248 })
249}
250
251// appPortRun reports network-reachable TCP listeners on the given ports,
252// noting whether a web server (80 or 443) is present to front them.
253func appPortRun(ports map[int]string, failMsg, passMsg string) func(context.Context, sys.System) []check.Result {
254 return func(_ context.Context, s sys.System) []check.Result {
255 pub, inc := publicListeners(s)
256 if inc != nil {
257 return inc
258 }
259 var bad []string
260 web := false
261 for _, l := range pub {
262 if l.Port == 80 || l.Port == 443 {
263 web = true
264 }
265 }
266 for _, l := range pub {
267 if n, ok := ports[l.Port]; ok && strings.HasPrefix(l.Proto, "tcp") {
268 bad = append(bad, l.String()+" "+n+", bound to "+exposure(l))
269 }
270 }
271 if len(bad) > 0 {
272 if web {
273 bad = append(bad, "a web server listens on port 80 or 443 on this machine: bind the application to 127.0.0.1 and publish it through that proxy")
274 } else {
275 bad = append(bad, "no web server listens on port 80 or 443 on this machine")
276 }
277 return []check.Result{check.FailR("sockets", failMsg, bad...)}
278 }
279 return []check.Result{check.PassR("sockets", passMsg)}
280 }
281}
282
283// portName labels a port with its well-known service, if any.
284func portName(p int) string {
285 if n, ok := sensitivePort(p); ok {
286 return " (" + n + ")"
287 }
288 return label(p)
289}
290
291func protoSuffix(p string) string {
292 if p == "" {
293 return ""
294 }
295 return "/" + p
296}

Also available as plain Markdown for notes, scripts and AI tools. Same facts, same source, no styling.

Cite This Page

If this page helped your work, please cite it. The version and web address are filled in; the access date is today's date in your browser. Every other style, and when credit is required, is on How to Cite OpenVAPT.

APA 7
OpenVAPT. (2026). NET-APP-002 Application Server Ports Reachable Without a Reverse Proxy (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/NET-APP-002.html

In text: (OpenVAPT, 2026). Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.

BibTeX
@misc{openvapt_net_app_002,
  author       = {{OpenVAPT}},
  title        = {{NET-APP-002 Application Server Ports Reachable Without a Reverse Proxy}},
  year         = {2026},
  howpublished = {\url{https://openvapt.com/checks/NET-APP-002.html}},
  note         = {OpenVAPT Check Catalogue, version 0.1.0-alpha.6. Accessed 16 October 2026}
}

Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.

Citing OpenVAPT does not mean OpenVAPT endorses, approves or has reviewed your work.