Node.js Debugger (Inspector) Enabled in a Service
Checks whether a Node.js service is started with --inspect, which opens the V8 debugger.
- Why It Matters
- The inspector has no authentication: anyone who can connect to it can run any JavaScript inside the application, as the service's user. Bound to 0.0.0.0 that is remote code execution; even on 127.0.0.1 any local user or server-side request forgery bug can reach it.
- How It Detects
- Reads ExecStart= and Environment= (including NODE_OPTIONS) of long-running services that run node, npm, yarn, pnpm or pm2, and flags --inspect, --inspect-brk and --inspect-wait. A network-reachable listener on port 9229 is also reported by NET-APP-001.
- How to Fix
- Remove --inspect from the service and from NODE_OPTIONS, run systemctl daemon-reload and restart. Debug on a development machine, or temporarily over an SSH tunnel to 127.0.0.1:9229.
- How to Verify
systemctl show -p ExecStart,Environment <unit> | grep -c inspect prints 0, and nothing listens on 9229.- CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H- Control Category
- Secure Configuration: CIS Controls v8 4; NIST CSF 2.0 PR.PS
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 16.7
- NIST CSF 2.0 Subcategory
- PR.PS-01
- CWE
- CWE-489, CWE-306
- OWASP Top 10
- A05:2021
- OWASP ASVS 4.0.3
- 14.3.2
- NIST SP 800-53 Rev. 5
- CM-7, CM-6
- References
- Node.js documentation, Debugging guide (security implications)
OWASP Node.js Security Cheat Sheet
Source Code
The complete file that defines this check. Run it with openvapt scan --only WEB-NODE-002, or read it offline with openvapt explain WEB-NODE-002.
internal/checks/web/runtimes.go · Lines 1-360View on GitHub at This Commit
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "path/filepath" |
| 7 | "regexp" |
| 8 | "sort" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/openvapt/openvapt/internal/check" |
| 14 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 15 | "github.com/openvapt/openvapt/internal/sys" |
| 16 | ) |
| 17 | |
| 18 | // Node.js and Python application checks. They read systemd service units |
| 19 | // (how the application is started), the Node.js version, and Django settings |
| 20 | // files. Settings files can hold secrets, so only file paths and setting |
| 21 | // names are reported, never values. |
| 22 | |
| 23 | // nodeEOL is the end-of-life date of each Node.js major release, from the |
| 24 | // Node.js release schedule (https://github.com/nodejs/Release). Odd majors |
| 25 | // are short-lived "current" releases. |
| 26 | var nodeEOL = map[int]string{ |
| 27 | 12: "2022-04-30", 14: "2023-04-30", 16: "2023-09-11", 17: "2022-06-01", 18: "2025-04-30", 19: "2023-06-01", |
| 28 | 20: "2026-04-30", 21: "2024-06-01", 22: "2027-04-30", 23: "2025-06-01", 24: "2028-04-30", 25: "2026-06-01", |
| 29 | 26: "2029-04-30", |
| 30 | } |
| 31 | |
| 32 | var nodeVersion = regexp.MustCompile(`v?(\d+)\.(\d+)\.(\d+)`) |
| 33 | |
| 34 | // unitCommand joins a unit's ExecStart= lines and Environment= values so |
| 35 | // that command-line flags and environment switches can be searched together. |
| 36 | func unitCommand(u collect.Unit) string { |
| 37 | return strings.Join(u.All("service.execstart"), " ") + " " + strings.Join(u.All("service.environment"), " ") |
| 38 | } |
| 39 | |
| 40 | var nodeRunner = regexp.MustCompile(`(^|[/\s])(node|nodejs|npm|npx|yarn|pnpm|pm2-runtime|pm2)(\s|$)`) |
| 41 | |
| 42 | func runsNode(u collect.Unit) bool { return nodeRunner.MatchString(u.Get("service.execstart")) } |
| 43 | |
| 44 | // unitResults runs fn over long-running service units, or skips if fn |
| 45 | // selects none. |
| 46 | func unitResults(ctx context.Context, s sys.System, applies func(collect.Unit) bool, judge func(collect.Unit) string, okMsg, failMsg string) []check.Result { |
| 47 | var n int |
| 48 | var bad []string |
| 49 | for _, u := range collect.Units(ctx, s) { |
| 50 | if !u.LongRunning() || !applies(u) { |
| 51 | continue |
| 52 | } |
| 53 | n++ |
| 54 | if why := judge(u); why != "" { |
| 55 | bad = append(bad, u.Name+": "+why) |
| 56 | } |
| 57 | } |
| 58 | if n == 0 { |
| 59 | return []check.Result{check.SkipR("systemd units", "No matching application services were found")} |
| 60 | } |
| 61 | if len(bad) > 0 { |
| 62 | return []check.Result{check.FailR("systemd units", failMsg, bad...)} |
| 63 | } |
| 64 | return []check.Result{check.PassR("systemd units", okMsg)} |
| 65 | } |
| 66 | |
| 67 | // --inspect-port only sets a port; it does not start the inspector. |
| 68 | var inspectFlag = regexp.MustCompile(`--inspect(-brk|-wait)?(=(\S+))?(\s|$)`) |
| 69 | var pyDevServer = regexp.MustCompile(`manage\.py\s+runserver|\bflask\s+run\b|-m\s+(http\.server|SimpleHTTPServer)\b|\buvicorn\b.*--reload|\bstreamlit\s+run\b`) |
| 70 | var flaskDebug = regexp.MustCompile(`(?i)\bFLASK_DEBUG=(1|true|yes|on)\b|\bFLASK_ENV=development\b|\bflask\b.*\s--debug\b|\bWERKZEUG_DEBUG_PIN=off\b`) |
| 71 | |
| 72 | // django settings patterns; DEBUG/ALLOWED_HOSTS/SECRET_KEY assignments at |
| 73 | // the start of a line, as Django's own generated settings write them. |
| 74 | var ( |
| 75 | djDebugOn = regexp.MustCompile(`(?m)^\s*DEBUG\s*=\s*True\b`) |
| 76 | djDebugSafe = regexp.MustCompile(`^(False\b|os\.|env|config\(|bool\()`) |
| 77 | djDebugAny = regexp.MustCompile(`(?m)^\s*DEBUG\s*=\s*(.*)$`) |
| 78 | djHostsAny = regexp.MustCompile(`(?m)^\s*ALLOWED_HOSTS\s*=\s*[\[(]\s*['"]\*['"]`) |
| 79 | djInsecure = regexp.MustCompile(`(?m)^\s*SECRET_KEY\s*=\s*['"]django-insecure-`) |
| 80 | djMarker = regexp.MustCompile(`INSTALLED_APPS|ROOT_URLCONF|DJANGO_SETTINGS_MODULE|django`) |
| 81 | ) |
| 82 | |
| 83 | type djangoFile struct{ path, text string } |
| 84 | |
| 85 | // djangoSettings returns Django settings files grouped by folder. |
| 86 | func djangoSettings(ctx context.Context, s sys.System) map[string][]djangoFile { |
| 87 | out := map[string][]djangoFile{} |
| 88 | for _, p := range collect.ProjectFiles(ctx, s, "settings.py", "settings/*.py") { |
| 89 | // Project folders are writable by site users, who could replace |
| 90 | // settings.py with a link to /etc/shadow, so links are refused. |
| 91 | r, err := sys.ReadFileNoFollow(ctx, s, p) |
| 92 | if err != nil || !djMarker.Match(r.Data) && !djDebugOn.Match(r.Data) { |
| 93 | continue |
| 94 | } |
| 95 | dir := filepath.Dir(p) |
| 96 | out[dir] = append(out[dir], djangoFile{p, string(r.Data)}) |
| 97 | } |
| 98 | return out |
| 99 | } |
| 100 | |
| 101 | func djangoCheck(ctx context.Context, s sys.System, judge func(dir []djangoFile) []string, failMsg string) []check.Result { |
| 102 | dirs := djangoSettings(ctx, s) |
| 103 | if len(dirs) == 0 { |
| 104 | return []check.Result{check.SkipR("django", "No Django settings files found")} |
| 105 | } |
| 106 | keys := make([]string, 0, len(dirs)) |
| 107 | for k := range dirs { |
| 108 | keys = append(keys, k) |
| 109 | } |
| 110 | sort.Strings(keys) |
| 111 | var out []check.Result |
| 112 | for _, k := range keys { |
| 113 | if bad := judge(dirs[k]); len(bad) > 0 { |
| 114 | out = append(out, check.FailR(k, failMsg, bad...)) |
| 115 | } else { |
| 116 | out = append(out, check.PassR(k, "No issue found")) |
| 117 | } |
| 118 | } |
| 119 | return out |
| 120 | } |
| 121 | |
| 122 | func matching(fs []djangoFile, re *regexp.Regexp, what string) []string { |
| 123 | var out []string |
| 124 | for _, f := range fs { |
| 125 | if re.MatchString(f.text) { |
| 126 | out = append(out, f.path+": "+what) |
| 127 | } |
| 128 | } |
| 129 | return out |
| 130 | } |
| 131 | |
| 132 | var refDjangoDeploy = check.Ref{Title: "Django documentation, Deployment checklist", URL: "https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/"} |
| 133 | var refNodeSec = check.Ref{Title: "OWASP Node.js Security Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html"} |
| 134 | |
| 135 | func init() { |
| 136 | check.Register(&check.Check{ |
| 137 | ID: "WEB-NODE-001", Title: "Node.js Version Past End of Life", Domain: check.DomainWeb, |
| 138 | Severity: check.High, Category: check.CatPatch, Effort: check.EffortHigh, |
| 139 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H", |
| 140 | Summary: "Checks whether the installed Node.js runtime is a release line that no longer receives security fixes.", |
| 141 | Rationale: "After end of life the Node.js project publishes no more security releases, so every later vulnerability in the runtime, its bundled OpenSSL or its HTTP parser stays open for good.", |
| 142 | Detection: "Runs node --version (or nodejs --version), a read-only command, and compares the major version with the Node.js release schedule's end-of-life dates.", |
| 143 | Fix: "Move to an Active or Maintenance LTS release (even-numbered majors), for example with the NodeSource or distribution packages, test the application, then restart its service.", |
| 144 | Verify: "node --version shows a supported LTS major.", |
| 145 | Refs: []check.Ref{{Title: "Node.js Release Working Group, release schedule", URL: "https://github.com/nodejs/Release"}, {Title: "endoflife.date, Node.js (community-maintained lifecycle data)", URL: "https://endoflife.date/nodejs"}}, |
| 146 | Standards: check.Standards{CWE: []string{"CWE-1104"}, OWASP: []string{"A06:2021"}, NIST: []string{"SI-2", "SA-22"}, CISTopic: "Use supported software"}, |
| 147 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 148 | out, err := s.Run(ctx, "node", "--version") |
| 149 | if err != nil { |
| 150 | out, err = s.Run(ctx, "nodejs", "--version") |
| 151 | } |
| 152 | if err != nil { |
| 153 | return []check.Result{check.SkipR("node", "Node.js is not installed")} |
| 154 | } |
| 155 | m := nodeVersion.FindStringSubmatch(out) |
| 156 | if m == nil { |
| 157 | return []check.Result{check.IncompleteR("node", "Could not read the Node.js version")} |
| 158 | } |
| 159 | major, _ := strconv.Atoi(m[1]) |
| 160 | eol, ok := nodeEOL[major] |
| 161 | if !ok { |
| 162 | if major < 12 { |
| 163 | return []check.Result{check.FailR("node", "Node.js "+m[0]+" is long past end of life")} |
| 164 | } |
| 165 | return []check.Result{check.PassR("node", "Node.js "+m[0]+" is newer than the built-in schedule")} |
| 166 | } |
| 167 | if eol <= time.Now().UTC().Format("2006-01-02") { |
| 168 | return []check.Result{check.FailR("node", fmt.Sprintf("Node.js %s reached end of life on %s", m[0], eol))} |
| 169 | } |
| 170 | return []check.Result{check.PassR("node", fmt.Sprintf("Node.js %s is supported until %s", m[0], eol))} |
| 171 | }, |
| 172 | }) |
| 173 | |
| 174 | check.Register(&check.Check{ |
| 175 | ID: "WEB-NODE-002", Title: "Node.js Debugger (Inspector) Enabled in a Service", Domain: check.DomainWeb, |
| 176 | Severity: check.Critical, Category: check.CatConfig, Effort: check.EffortLow, |
| 177 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", |
| 178 | Summary: "Checks whether a Node.js service is started with --inspect, which opens the V8 debugger.", |
| 179 | Rationale: "The inspector has no authentication: anyone who can connect to it can run any JavaScript inside the application, as the service's user. Bound to 0.0.0.0 that is remote code execution; even on 127.0.0.1 any local user or server-side request forgery bug can reach it.", |
| 180 | Detection: "Reads ExecStart= and Environment= (including NODE_OPTIONS) of long-running services that run node, npm, yarn, pnpm or pm2, and flags --inspect, --inspect-brk and --inspect-wait. A network-reachable listener on port 9229 is also reported by NET-APP-001.", |
| 181 | Fix: "Remove --inspect from the service and from NODE_OPTIONS, run systemctl daemon-reload and restart. Debug on a development machine, or temporarily over an SSH tunnel to 127.0.0.1:9229.", |
| 182 | Verify: "systemctl show -p ExecStart,Environment <unit> | grep -c inspect prints 0, and nothing listens on 9229.", |
| 183 | Refs: []check.Ref{{Title: "Node.js documentation, Debugging guide (security implications)", URL: "https://nodejs.org/learn/getting-started/debugging"}, refNodeSec}, |
| 184 | Standards: check.Standards{CWE: []string{"CWE-489", "CWE-306"}, OWASP: []string{"A05:2021"}, NIST: []string{"CM-7", "CM-6"}}, |
| 185 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 186 | return unitResults(ctx, s, runsNode, func(u collect.Unit) string { |
| 187 | m := inspectFlag.FindStringSubmatch(unitCommand(u)) |
| 188 | if m == nil { |
| 189 | return "" |
| 190 | } |
| 191 | if m[3] != "" && !strings.HasPrefix(m[3], "127.") && !strings.HasPrefix(m[3], "localhost") && strings.Contains(m[3], ":") { |
| 192 | return "starts the inspector on a network address (" + m[0] + ")" |
| 193 | } |
| 194 | return "starts the inspector (" + m[0] + ")" |
| 195 | }, "No Node.js service starts the inspector", "Node.js services start the debugger") |
| 196 | }, |
| 197 | }) |
| 198 | |
| 199 | check.Register(&check.Check{ |
| 200 | ID: "WEB-NODE-003", Title: "Node.js Service Not Running in Production Mode", Domain: check.DomainWeb, |
| 201 | Severity: check.Low, Category: check.CatConfig, Effort: check.EffortLow, |
| 202 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N", |
| 203 | Summary: "Checks whether Node.js services set NODE_ENV=production.", |
| 204 | Rationale: "Express and many other frameworks treat a missing NODE_ENV as development: error pages then include stack traces with file paths, and caching and other production safeguards are off.", |
| 205 | Detection: "For long-running services whose ExecStart= runs node, npm, yarn, pnpm or pm2, reads Environment= and fails when NODE_ENV is missing or not production. Variables loaded with EnvironmentFile= are read only for NODE_ENV.", |
| 206 | Fix: "Add Environment=NODE_ENV=production to the [Service] section (systemctl edit <unit>), run systemctl daemon-reload and restart the service.", |
| 207 | Verify: "systemctl show -p Environment <unit> includes NODE_ENV=production.", |
| 208 | Refs: []check.Ref{{Title: "Express documentation, Production best practices: performance and reliability", URL: "https://expressjs.com/en/advanced/best-practice-performance/"}, refNodeSec}, |
| 209 | Standards: check.Standards{CWE: []string{"CWE-489", "CWE-209"}, OWASP: []string{"A05:2021"}, NIST: []string{"CM-6"}}, |
| 210 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 211 | return unitResults(ctx, s, runsNode, func(u collect.Unit) string { |
| 212 | env := u.Env() |
| 213 | v, ok := env["NODE_ENV"] |
| 214 | if !ok { |
| 215 | v, ok = envFileValue(ctx, s, u, "NODE_ENV") |
| 216 | } |
| 217 | switch { |
| 218 | case !ok: |
| 219 | return "NODE_ENV is not set" |
| 220 | case v != "production": |
| 221 | return "NODE_ENV=" + v |
| 222 | } |
| 223 | return "" |
| 224 | }, "Node.js services run with NODE_ENV=production", "Node.js services are not in production mode") |
| 225 | }, |
| 226 | }) |
| 227 | |
| 228 | check.Register(&check.Check{ |
| 229 | ID: "WEB-PY-001", Title: "Django Debug Mode Enabled", Domain: check.DomainWeb, |
| 230 | Severity: check.High, Category: check.CatExposure, Effort: check.EffortLow, |
| 231 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 232 | Summary: "Checks Django settings files for DEBUG = True with no production override.", |
| 233 | Rationale: "With DEBUG on, every error page shows source code, local variables, SQL queries and most settings, and Django keeps every SQL query in memory. Attackers trigger errors on purpose to read them.", |
| 234 | Detection: "Finds settings.py and settings/*.py files that look like Django settings in /srv, /opt, /var/www, home directories and web roots (three folders deep), and fails when a folder sets DEBUG = True and no file in the same folder sets DEBUG = False or reads it from the environment.", |
| 235 | Fix: "Set DEBUG = False in production, preferably from the environment (DEBUG = os.environ.get('DJANGO_DEBUG') == '1'), set ALLOWED_HOSTS, and restart the application. Run python manage.py check --deploy.", |
| 236 | Verify: "python manage.py check --deploy reports no DEBUG warning.", |
| 237 | Refs: []check.Ref{refDjangoDeploy, {Title: "Django settings reference, DEBUG", URL: "https://docs.djangoproject.com/en/6.1/ref/settings/#debug"}}, |
| 238 | Standards: check.Standards{CWE: []string{"CWE-489", "CWE-215"}, OWASP: []string{"A05:2021"}, ASVS: []string{"14.3.2"}, WSTG: []string{"WSTG-ERRH-01"}, NIST: []string{"CM-6"}}, |
| 239 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 240 | return djangoCheck(ctx, s, func(fs []djangoFile) []string { |
| 241 | // Python keeps the last assignment in a file. A folder is safe |
| 242 | // when some file's final DEBUG is False or read from the |
| 243 | // environment (a production module overriding a base one). |
| 244 | var on []string |
| 245 | for _, f := range fs { |
| 246 | all := djDebugAny.FindAllStringSubmatch(f.text, -1) |
| 247 | if len(all) == 0 { |
| 248 | continue |
| 249 | } |
| 250 | last := strings.TrimSpace(all[len(all)-1][1]) |
| 251 | if djDebugSafe.MatchString(last) { |
| 252 | return nil |
| 253 | } |
| 254 | if strings.HasPrefix(last, "True") { |
| 255 | on = append(on, f.path+": DEBUG = True") |
| 256 | } |
| 257 | } |
| 258 | return on |
| 259 | }, "Django debug mode is on") |
| 260 | }, |
| 261 | }) |
| 262 | |
| 263 | check.Register(&check.Check{ |
| 264 | ID: "WEB-PY-002", Title: "Django Accepts Any Host Header", Domain: check.DomainWeb, |
| 265 | Severity: check.Medium, Category: check.CatWebApp, Effort: check.EffortLow, |
| 266 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N", |
| 267 | Summary: "Checks Django settings for ALLOWED_HOSTS = ['*'].", |
| 268 | Rationale: "ALLOWED_HOSTS protects against Host header attacks. With a wildcard, an attacker can make Django build password-reset and other absolute links that point at the attacker's domain, and can poison shared caches.", |
| 269 | Detection: "Reads the same Django settings files as WEB-PY-001 and flags an ALLOWED_HOSTS list or tuple that starts with '*'.", |
| 270 | Fix: "List the real domain names: ALLOWED_HOSTS = ['example.com', 'www.example.com'], and restart the application.", |
| 271 | Verify: "python manage.py check --deploy", |
| 272 | Refs: []check.Ref{refDjangoDeploy, {Title: "Django settings reference, ALLOWED_HOSTS", URL: "https://docs.djangoproject.com/en/6.1/ref/settings/#allowed-hosts"}}, |
| 273 | Standards: check.Standards{CWE: []string{"CWE-346"}, OWASP: []string{"A05:2021"}, WSTG: []string{"WSTG-INPV-17"}, NIST: []string{"CM-6", "SI-10"}}, |
| 274 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 275 | return djangoCheck(ctx, s, func(fs []djangoFile) []string { return matching(fs, djHostsAny, "ALLOWED_HOSTS allows any host") }, "Django accepts any Host header") |
| 276 | }, |
| 277 | }) |
| 278 | |
| 279 | check.Register(&check.Check{ |
| 280 | ID: "WEB-PY-003", Title: "Django Uses the Generated Development Secret Key", Domain: check.DomainWeb, |
| 281 | Severity: check.High, Category: check.CatCrypto, Effort: check.EffortLow, |
| 282 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N", |
| 283 | Summary: "Checks Django settings for a SECRET_KEY that starts with django-insecure-, the marker of the key generated by startproject.", |
| 284 | Rationale: "SECRET_KEY signs sessions, password-reset tokens and other data. The generated key sits in source files that are usually copied into repositories and backups; anyone who has it can forge signed data and, with some session backends, take over accounts.", |
| 285 | Detection: "Reads the same Django settings files as WEB-PY-001 and flags SECRET_KEY = 'django-insecure-...'. The key itself is never copied into the report.", |
| 286 | Fix: "Generate a new key (python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'), load it from the environment or a root-readable file (SECRET_KEY = os.environ['DJANGO_SECRET_KEY']), and restart. Changing the key logs all users out.", |
| 287 | Verify: "python manage.py check --deploy reports no SECRET_KEY warning.", |
| 288 | Refs: []check.Ref{refDjangoDeploy, {Title: "Django settings reference, SECRET_KEY", URL: "https://docs.djangoproject.com/en/6.1/ref/settings/#secret-key"}}, |
| 289 | Standards: check.Standards{CWE: []string{"CWE-798", "CWE-321"}, OWASP: []string{"A02:2021", "A05:2021"}, ASVS: []string{"2.10.4"}, NIST: []string{"IA-5", "SC-12"}}, |
| 290 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 291 | return djangoCheck(ctx, s, func(fs []djangoFile) []string { |
| 292 | return matching(fs, djInsecure, "uses the generated django-insecure SECRET_KEY") |
| 293 | }, "Django uses the generated development SECRET_KEY") |
| 294 | }, |
| 295 | }) |
| 296 | |
| 297 | check.Register(&check.Check{ |
| 298 | ID: "WEB-PY-004", Title: "Python Interactive Debugger Enabled in a Service", Domain: check.DomainWeb, |
| 299 | Severity: check.Critical, Category: check.CatConfig, Effort: check.EffortLow, |
| 300 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", |
| 301 | Summary: "Checks whether a service runs Flask or Werkzeug with the interactive debugger enabled.", |
| 302 | Rationale: "The Werkzeug debugger lets a visitor run Python code from an error page. Its PIN has been bypassed and brute-forced in practice, and with WERKZEUG_DEBUG_PIN=off there is no protection at all: this is remote code execution.", |
| 303 | Detection: "Reads ExecStart= and Environment= of long-running services and flags FLASK_DEBUG=1, FLASK_ENV=development, flask run --debug and WERKZEUG_DEBUG_PIN=off.", |
| 304 | Fix: "Remove the debug switches, run the application under a production WSGI server (Gunicorn, uWSGI or mod_wsgi) behind nginx or Apache, run systemctl daemon-reload and restart the service.", |
| 305 | Verify: "systemctl show -p ExecStart,Environment <unit> shows no debug switches.", |
| 306 | Refs: []check.Ref{{Title: "Werkzeug documentation, Debugging Applications", URL: "https://werkzeug.palletsprojects.com/en/stable/debug/"}, {Title: "Flask documentation, Debug mode", URL: "https://flask.palletsprojects.com/en/stable/quickstart/#debug-mode"}}, |
| 307 | Standards: check.Standards{CWE: []string{"CWE-489", "CWE-94"}, OWASP: []string{"A05:2021"}, NIST: []string{"CM-7", "CM-6"}}, |
| 308 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 309 | return unitResults(ctx, s, func(collect.Unit) bool { return true }, func(u collect.Unit) string { |
| 310 | if m := flaskDebug.FindString(unitCommand(u)); m != "" { |
| 311 | return "enables the debugger (" + m + ")" |
| 312 | } |
| 313 | return "" |
| 314 | }, "No service enables the Flask or Werkzeug debugger", "Services enable the Python interactive debugger") |
| 315 | }, |
| 316 | }) |
| 317 | |
| 318 | check.Register(&check.Check{ |
| 319 | ID: "WEB-PY-005", Title: "Python Development Server Used in a Service", Domain: check.DomainWeb, |
| 320 | Severity: check.Medium, Category: check.CatConfig, Effort: check.EffortMedium, |
| 321 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L", |
| 322 | Summary: "Checks whether a service runs a development web server such as manage.py runserver, flask run or python -m http.server.", |
| 323 | Rationale: "Development servers are documented as unsuitable for production: they have not been security-audited, handle load and slow clients poorly and often run with debugging features. python -m http.server publishes a whole folder.", |
| 324 | Detection: "Reads ExecStart= of long-running services and flags manage.py runserver, flask run, python -m http.server or SimpleHTTPServer, uvicorn --reload and streamlit run.", |
| 325 | Fix: "Serve the application with a production server (Gunicorn or uWSGI for WSGI, Uvicorn without --reload or Hypercorn for ASGI) bound to 127.0.0.1, behind nginx or Apache. Serve static files from the web server.", |
| 326 | Verify: "systemctl show -p ExecStart <unit> shows a production server.", |
| 327 | Refs: []check.Ref{{Title: "Django documentation, django-admin runserver (not for production)", URL: "https://docs.djangoproject.com/en/6.1/ref/django-admin/#runserver"}, {Title: "Flask documentation, Deploying to Production", URL: "https://flask.palletsprojects.com/en/stable/deploying/"}}, |
| 328 | Standards: check.Standards{CWE: []string{"CWE-489", "CWE-1188"}, OWASP: []string{"A05:2021"}, NIST: []string{"CM-7"}}, |
| 329 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 330 | return unitResults(ctx, s, func(collect.Unit) bool { return true }, func(u collect.Unit) string { |
| 331 | if m := pyDevServer.FindString(u.Get("service.execstart")); m != "" { |
| 332 | return "runs a development server (" + m + ")" |
| 333 | } |
| 334 | return "" |
| 335 | }, "No service runs a Python development server", "Services run Python development servers") |
| 336 | }, |
| 337 | }) |
| 338 | } |
| 339 | |
| 340 | // envFileValue reads one variable from a unit's EnvironmentFile= files. The |
| 341 | // files may hold secrets, so only the requested variable is extracted. |
| 342 | func envFileValue(ctx context.Context, s sys.System, u collect.Unit, name string) (string, bool) { |
| 343 | for _, p := range u.All("service.environmentfile") { |
| 344 | p = strings.TrimPrefix(strings.TrimSpace(p), "-") |
| 345 | if !strings.HasPrefix(p, "/") { |
| 346 | continue |
| 347 | } |
| 348 | r, err := s.ReadFile(ctx, p) |
| 349 | if err != nil { |
| 350 | continue |
| 351 | } |
| 352 | for _, line := range strings.Split(string(r.Data), "\n") { |
| 353 | k, v, ok := strings.Cut(strings.TrimPrefix(strings.TrimSpace(line), "export "), "=") |
| 354 | if ok && strings.TrimSpace(k) == name { |
| 355 | return strings.Trim(strings.TrimSpace(v), `"'`), true |
| 356 | } |
| 357 | } |
| 358 | } |
| 359 | return "", false |
| 360 | } |
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.
OpenVAPT. (2026). WEB-NODE-002 Node.js Debugger (Inspector) Enabled in a Service (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/WEB-NODE-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.
@misc{openvapt_web_node_002,
author = {{OpenVAPT}},
title = {{WEB-NODE-002 Node.js Debugger (Inspector) Enabled in a Service}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/WEB-NODE-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.