Apache Serves Repository and Environment Files
No DirectoryMatch, FilesMatch or RedirectMatch rule denies .git, .svn, .hg or .env paths, so they are served if present under a document root.
- Why It Matters
- Repository folders rebuild the source code and its history; .env files hold database passwords and application keys. Debian's default only protects .ht* files. One rule for every dot path removes the whole class, including files deployed by mistake later. The rating is Low because this is a missing safety net, not an exposure: when such a file is actually present under a document root, WEB-APP-009 and WEB-DOC-001 report it at their own higher severity, and when none is present the check passes and only notes the missing rule.
- How It Detects
- Looks for a block or rule whose pattern covers dot-prefixed names: DirectoryMatch or LocationMatch with a dot pattern, FilesMatch starting with ^\. , or RedirectMatch for .git or .env. When none exists, checks each configured document root (metadata only, symlinks not followed) for a .git, .svn, .hg, .env or .env.* entry; Fails at Low when one is found and the rule is missing, Passes with a note when none is found.
- How to Fix
- Add to the server configuration: <DirectoryMatch "/\.(git|svn|hg)"> Require all denied </DirectoryMatch> and <FilesMatch "^\.(env|git|svn)"> Require all denied </FilesMatch> , then reload.
- How to Verify
curl -s -o /dev/null -w '%{http_code}' https://<site>/.git/HEAD should print 403 or 404.- CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N- Control Category
- Information Exposure: CIS Controls v8 3, 16; NIST CSF 2.0 PR.DS
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 3.3
- NIST CSF 2.0 Subcategory
- PR.AA-05, PR.DS-01
- OWASP WSTG v4.2
- WSTG-CONF-04
- CWE
- CWE-538, CWE-527
- OWASP Top 10
- A05:2021
- OWASP ASVS 4.0.3
- 12.5.1, 14.3.3
- NIST SP 800-53 Rev. 5
- CM-7, SC-28
- Benchmark Topic
- Web server access to hidden files (topic only, no benchmark text)
- References
- Apache HTTP Server, DirectoryMatch directive
OWASP WSTG-CONF-04, Review Old Backup and Unreferenced Files
Source Code
The complete file that defines this check. Run it with openvapt scan --only WEB-APA-010, or read it offline with openvapt explain WEB-APA-010.
internal/checks/web/serverconf.go · Lines 1-443View on GitHub at This Commit
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "sort" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/openvapt/openvapt/internal/check" |
| 10 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 11 | "github.com/openvapt/openvapt/internal/sys" |
| 12 | ) |
| 13 | |
| 14 | // serverConf is the merged, comment-free configuration of one web server |
| 15 | // (nginx or Apache) as read from its main file and every include it names |
| 16 | // under the server's own configuration folder. It is read-only input for the |
| 17 | // WEB-NGX and WEB-APA hardening checks; nothing is executed or reloaded. |
| 18 | type serverConf struct { |
| 19 | Paths []string // files that were read, in include order |
| 20 | Lines []confLine // directives with comments removed |
| 21 | Complete bool // false when any file was truncated |
| 22 | Found bool // at least one configuration file was readable |
| 23 | Present bool // the server appears installed (folder or binary exists) |
| 24 | kind serverKind // nginx or apache |
| 25 | skip check.Result // ready-made Skip or Incomplete result when !Found |
| 26 | serverRoot string // Apache ServerRoot when set and under the prefixes |
| 27 | // tlsFloor is the system crypto library floor, read only for rules that |
| 28 | // set wantFloor. A directive that lists TLS 1.0 or 1.1 cannot take |
| 29 | // effect when the library already refuses those versions. |
| 30 | tlsFloor collect.TLSFloor |
| 31 | } |
| 32 | |
| 33 | type confLine struct { |
| 34 | Key string // lower-case directive name |
| 35 | Value string // the rest of the line, trimmed, without a trailing ';' |
| 36 | Raw string // the trimmed original line, for evidence |
| 37 | File string |
| 38 | } |
| 39 | |
| 40 | type serverKind struct { |
| 41 | name string |
| 42 | roots []string // configuration entry points (globs allowed) |
| 43 | includes []string // directive names that pull in other files |
| 44 | prefixes []string // include targets must stay under these folders |
| 45 | markers []string // paths whose existence means the server is installed |
| 46 | } |
| 47 | |
| 48 | var nginxKind = serverKind{ |
| 49 | name: "nginx", |
| 50 | roots: []string{"/etc/nginx/nginx.conf", "/usr/local/nginx/conf/nginx.conf"}, |
| 51 | includes: []string{"include"}, |
| 52 | prefixes: []string{"/etc/nginx/", "/usr/local/nginx/conf/"}, |
| 53 | markers: []string{"/etc/nginx", "/usr/sbin/nginx", "/usr/local/nginx"}, |
| 54 | } |
| 55 | |
| 56 | var apacheKind = serverKind{ |
| 57 | name: "Apache", |
| 58 | roots: []string{"/etc/apache2/apache2.conf", "/etc/httpd/conf/httpd.conf", "/etc/apache2/httpd.conf"}, |
| 59 | includes: []string{"include", "includeoptional"}, |
| 60 | prefixes: []string{"/etc/apache2/", "/etc/httpd/"}, |
| 61 | markers: []string{"/etc/apache2", "/etc/httpd", "/usr/sbin/apache2", "/usr/sbin/httpd"}, |
| 62 | } |
| 63 | |
| 64 | // readServerConf reads the configuration tree of one web server. Includes are |
| 65 | // followed only when they stay under the server's own configuration folders, |
| 66 | // to a bounded depth, and each file is read once. |
| 67 | func readServerConf(ctx context.Context, s sys.System, k serverKind) serverConf { |
| 68 | c := serverConf{kind: k} |
| 69 | for _, m := range k.markers { |
| 70 | if _, err := s.Stat(m); err == nil { |
| 71 | c.Present = true |
| 72 | break |
| 73 | } |
| 74 | } |
| 75 | c.Complete = true |
| 76 | seen := map[string]bool{} |
| 77 | for _, root := range k.roots { |
| 78 | for _, p := range expand(s, root) { |
| 79 | readConfTree(ctx, s, &c, p, seen, 0) |
| 80 | } |
| 81 | } |
| 82 | c.Found = len(c.Paths) > 0 |
| 83 | if !c.Found { |
| 84 | target := strings.Join(k.roots, ", ") |
| 85 | if c.Present { |
| 86 | c.skip = check.IncompleteR(target, k.name+" appears installed but its configuration could not be read") |
| 87 | } else { |
| 88 | c.skip = check.SkipR(target, k.name+" is not installed") |
| 89 | } |
| 90 | } |
| 91 | return c |
| 92 | } |
| 93 | |
| 94 | func expand(s sys.System, pattern string) []string { |
| 95 | if !strings.ContainsAny(pattern, "*?[") { |
| 96 | return []string{pattern} |
| 97 | } |
| 98 | m, _ := s.Glob(pattern) |
| 99 | sort.Strings(m) |
| 100 | return m |
| 101 | } |
| 102 | |
| 103 | func readConfTree(ctx context.Context, s sys.System, c *serverConf, path string, seen map[string]bool, depth int) { |
| 104 | if depth > 16 || seen[path] || len(c.Paths) >= 200 { |
| 105 | return |
| 106 | } |
| 107 | seen[path] = true |
| 108 | r, err := s.ReadFile(ctx, path) |
| 109 | if err != nil { |
| 110 | return |
| 111 | } |
| 112 | c.Paths = append(c.Paths, path) |
| 113 | c.Complete = c.Complete && r.Complete |
| 114 | sc := bufio.NewScanner(strings.NewReader(string(r.Data))) |
| 115 | sc.Buffer(make([]byte, 64*1024), 1024*1024) |
| 116 | for sc.Scan() { |
| 117 | raw := strings.TrimSpace(sc.Text()) |
| 118 | if i := strings.Index(raw, "#"); i >= 0 { |
| 119 | raw = strings.TrimSpace(raw[:i]) |
| 120 | } |
| 121 | if raw == "" { |
| 122 | continue |
| 123 | } |
| 124 | for _, stmt := range splitStatements(c.kind, raw) { |
| 125 | c.addLine(ctx, s, path, stmt, seen, depth) |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // splitStatements breaks one nginx line into its statements, so that |
| 131 | // "server { listen 443 ssl; root /srv; }" yields the block opener and each |
| 132 | // directive separately. Apache lines are one directive each and are returned |
| 133 | // unchanged (its regex patterns may legitimately contain braces). |
| 134 | func splitStatements(k serverKind, raw string) []string { |
| 135 | if k.name != nginxKind.name { |
| 136 | return []string{raw} |
| 137 | } |
| 138 | var out []string |
| 139 | var cur strings.Builder |
| 140 | flush := func() { |
| 141 | if t := strings.TrimSpace(cur.String()); t != "" { |
| 142 | out = append(out, t) |
| 143 | } |
| 144 | cur.Reset() |
| 145 | } |
| 146 | for _, r := range raw { |
| 147 | switch r { |
| 148 | case ';', '{', '}': |
| 149 | flush() |
| 150 | default: |
| 151 | cur.WriteRune(r) |
| 152 | } |
| 153 | } |
| 154 | flush() |
| 155 | return out |
| 156 | } |
| 157 | |
| 158 | // addLine records one statement and follows it when it is an include. |
| 159 | func (c *serverConf) addLine(ctx context.Context, s sys.System, path, raw string, seen map[string]bool, depth int) { |
| 160 | line := strings.TrimSpace(strings.TrimSuffix(raw, ";")) |
| 161 | f := strings.Fields(line) |
| 162 | if len(f) == 0 { |
| 163 | return |
| 164 | } |
| 165 | key := strings.ToLower(f[0]) |
| 166 | val := strings.TrimSpace(strings.TrimPrefix(line, f[0])) |
| 167 | c.Lines = append(c.Lines, confLine{Key: key, Value: val, Raw: raw, File: path}) |
| 168 | if key == "serverroot" { |
| 169 | if root := strings.Trim(val, `"'`); underPrefixes(root+"/", c.kind.prefixes) { |
| 170 | c.serverRoot = strings.TrimSuffix(root, "/") |
| 171 | } |
| 172 | return |
| 173 | } |
| 174 | if !isInclude(c.kind, key) { |
| 175 | return |
| 176 | } |
| 177 | for _, p := range f[1:] { |
| 178 | p = strings.Trim(p, `"'`) |
| 179 | if !strings.HasPrefix(p, "/") { |
| 180 | // Relative includes resolve against the server root: an |
| 181 | // explicit ServerRoot when seen, otherwise the configuration |
| 182 | // folder the current file lives in (/etc/nginx, /etc/httpd). |
| 183 | p = c.baseDir(path) + "/" + p |
| 184 | } |
| 185 | if !underPrefixes(p, c.kind.prefixes) { |
| 186 | continue |
| 187 | } |
| 188 | for _, file := range expand(s, p) { |
| 189 | readConfTree(ctx, s, c, file, seen, depth+1) |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | func (c *serverConf) baseDir(current string) string { |
| 195 | if c.serverRoot != "" { |
| 196 | return c.serverRoot |
| 197 | } |
| 198 | for _, pre := range c.kind.prefixes { |
| 199 | if strings.HasPrefix(current, pre) { |
| 200 | return strings.TrimSuffix(pre, "/") |
| 201 | } |
| 202 | } |
| 203 | return strings.TrimSuffix(c.kind.prefixes[0], "/") |
| 204 | } |
| 205 | |
| 206 | func isInclude(k serverKind, key string) bool { |
| 207 | for _, i := range k.includes { |
| 208 | if key == i { |
| 209 | return true |
| 210 | } |
| 211 | } |
| 212 | return false |
| 213 | } |
| 214 | |
| 215 | func underPrefixes(p string, prefixes []string) bool { |
| 216 | if strings.Contains(p, "..") { |
| 217 | return false |
| 218 | } |
| 219 | for _, pre := range prefixes { |
| 220 | if strings.HasPrefix(p, pre) { |
| 221 | return true |
| 222 | } |
| 223 | } |
| 224 | return false |
| 225 | } |
| 226 | |
| 227 | // directive returns every line whose directive name matches key. |
| 228 | func (c serverConf) directive(key string) []confLine { |
| 229 | var out []confLine |
| 230 | for _, l := range c.Lines { |
| 231 | if l.Key == strings.ToLower(key) { |
| 232 | out = append(out, l) |
| 233 | } |
| 234 | } |
| 235 | return out |
| 236 | } |
| 237 | |
| 238 | // has reports whether any directive named key has a value for which want |
| 239 | // returns true. Values are compared in lower case. |
| 240 | func (c serverConf) has(key string, want func(string) bool) bool { |
| 241 | for _, l := range c.directive(key) { |
| 242 | if want(strings.ToLower(l.Value)) { |
| 243 | return true |
| 244 | } |
| 245 | } |
| 246 | return false |
| 247 | } |
| 248 | |
| 249 | // anyValue reports whether any directive named key is present at all. |
| 250 | func (c serverConf) anyValue(key string) bool { return len(c.directive(key)) > 0 } |
| 251 | |
| 252 | // evidenceFor renders the matching lines as "file: line" evidence. |
| 253 | func (c serverConf) evidenceFor(keys ...string) []string { |
| 254 | var out []string |
| 255 | for _, k := range keys { |
| 256 | for _, l := range c.directive(k) { |
| 257 | out = append(out, l.File+": "+l.Raw) |
| 258 | } |
| 259 | } |
| 260 | return out |
| 261 | } |
| 262 | |
| 263 | // containsBlock reports whether a block opener matching all needles exists, |
| 264 | // for example a "location ~ /\." block in nginx or a <DirectoryMatch "^\.|\/\."> |
| 265 | // in Apache. The comparison is case-insensitive on the raw line. |
| 266 | func (c serverConf) containsBlock(needles ...string) bool { |
| 267 | for _, l := range c.Lines { |
| 268 | raw := strings.ToLower(l.Raw) |
| 269 | ok := true |
| 270 | for _, n := range needles { |
| 271 | if !strings.Contains(raw, strings.ToLower(n)) { |
| 272 | ok = false |
| 273 | break |
| 274 | } |
| 275 | } |
| 276 | if ok { |
| 277 | return true |
| 278 | } |
| 279 | } |
| 280 | return false |
| 281 | } |
| 282 | |
| 283 | // serverRule is one configuration-file hardening check for nginx or Apache. |
| 284 | // evaluate returns bad=true with evidence when the weakness is present. |
| 285 | type serverRule struct { |
| 286 | id, title string |
| 287 | sev check.Severity |
| 288 | cat check.Category |
| 289 | cvss, summary, rationale, detection, fix string |
| 290 | verify string |
| 291 | refs []check.Ref |
| 292 | std check.Standards |
| 293 | kind serverKind |
| 294 | evaluate func(c serverConf) (bad bool, ev []string) |
| 295 | // evaluateSys, when set, replaces evaluate for rules that must also look |
| 296 | // at the file system (for example whether a document root holds the |
| 297 | // files a missing deny rule would expose). |
| 298 | evaluateSys func(ctx context.Context, s sys.System, c serverConf) (bad bool, ev []string) |
| 299 | // skip, when set and true, reports Skip with the message before anything |
| 300 | // is evaluated (for example a TLS rule when no site is served over TLS). |
| 301 | skip func(c serverConf) (bool, string) |
| 302 | // wantFloor asks for the system TLS floor to be read into c.tlsFloor. |
| 303 | wantFloor bool |
| 304 | // pass, when set, replaces passMsg so a Pass can say why (for example |
| 305 | // that the library floor, not the directive, forbids TLS 1.0). |
| 306 | pass func(c serverConf) string |
| 307 | // incomplete, when set, turns a would-be Pass into Incomplete when the |
| 308 | // configuration leaves the setting undecidable (for example a default |
| 309 | // that depends on the software version). It never changes a Fail. |
| 310 | incomplete func(c serverConf) (bool, string) |
| 311 | passMsg string |
| 312 | } |
| 313 | |
| 314 | func registerServerRule(r serverRule) { |
| 315 | effort := check.EffortLow |
| 316 | check.Register(&check.Check{ |
| 317 | ID: r.id, Title: r.title, Domain: check.DomainWeb, Severity: r.sev, Category: r.cat, Effort: effort, CVSS: r.cvss, |
| 318 | Summary: r.summary, Rationale: r.rationale, Detection: r.detection, Fix: r.fix, Verify: r.verify, Refs: r.refs, Standards: r.std, |
| 319 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 320 | c := readServerConf(ctx, s, r.kind) |
| 321 | if !c.Found { |
| 322 | return []check.Result{c.skip} |
| 323 | } |
| 324 | target := c.Paths[0] |
| 325 | if r.skip != nil { |
| 326 | if hit, msg := r.skip(c); hit { |
| 327 | return []check.Result{check.SkipR(target, msg)} |
| 328 | } |
| 329 | } |
| 330 | if r.wantFloor { |
| 331 | c.tlsFloor = collect.SystemTLSFloor(ctx, s) |
| 332 | } |
| 333 | var bad bool |
| 334 | var ev []string |
| 335 | if r.evaluateSys != nil { |
| 336 | bad, ev = r.evaluateSys(ctx, s, c) |
| 337 | } else { |
| 338 | bad, ev = r.evaluate(c) |
| 339 | } |
| 340 | if bad { |
| 341 | return []check.Result{check.FailR(target, r.summary, ev...)} |
| 342 | } |
| 343 | if !c.Complete { |
| 344 | return []check.Result{check.IncompleteR(target, "Configuration input was truncated; the setting could not be confirmed")} |
| 345 | } |
| 346 | if r.incomplete != nil { |
| 347 | if hit, msg := r.incomplete(c); hit { |
| 348 | return []check.Result{check.IncompleteR(target, msg, ev...)} |
| 349 | } |
| 350 | } |
| 351 | msg := r.passMsg |
| 352 | if r.pass != nil { |
| 353 | msg = r.pass(c) |
| 354 | } |
| 355 | if msg == "" { |
| 356 | msg = "The hardening setting is present" |
| 357 | } |
| 358 | return []check.Result{check.PassR(target, msg, ev...)} |
| 359 | }, |
| 360 | }) |
| 361 | } |
| 362 | |
| 363 | // Shared TLS judgement for nginx ssl_protocols / Apache SSLProtocol values. |
| 364 | |
| 365 | // weakTLSProtocols returns the protocol names in v that are below TLS 1.2. |
| 366 | // nginx lists the allowed versions ("TLSv1 TLSv1.1 TLSv1.2"); Apache uses |
| 367 | // "+TLSv1.2" and "-all" style tokens. Both are handled: an Apache value |
| 368 | // starting with "all" or "-all" is expanded to the explicit list first. |
| 369 | func weakTLSProtocols(v string) []string { |
| 370 | v = strings.ToLower(v) |
| 371 | allowed := map[string]bool{} |
| 372 | fields := strings.Fields(v) |
| 373 | for _, f := range fields { |
| 374 | switch { |
| 375 | case f == "all" || f == "+all": |
| 376 | for _, p := range []string{"sslv3", "tlsv1", "tlsv1.1", "tlsv1.2", "tlsv1.3"} { |
| 377 | allowed[p] = true |
| 378 | } |
| 379 | case f == "-all": |
| 380 | allowed = map[string]bool{} |
| 381 | case strings.HasPrefix(f, "-"): |
| 382 | delete(allowed, strings.TrimPrefix(f, "-")) |
| 383 | case strings.HasPrefix(f, "+"): |
| 384 | allowed[strings.TrimPrefix(f, "+")] = true |
| 385 | default: |
| 386 | allowed[f] = true |
| 387 | } |
| 388 | } |
| 389 | var weak []string |
| 390 | for _, p := range []string{"sslv2", "sslv3", "tlsv1", "tlsv1.1"} { |
| 391 | if allowed[p] { |
| 392 | weak = append(weak, p) |
| 393 | } |
| 394 | } |
| 395 | return weak |
| 396 | } |
| 397 | |
| 398 | // weakCipherTokens are substrings whose presence in a cipher list marks a |
| 399 | // suite the Mozilla TLS guidelines and RFC 7525 advise against. A list that |
| 400 | // starts with "!" or "-" for the token is an exclusion and is not flagged. |
| 401 | var weakCipherTokens = []string{"null", "export", "des", "rc4", "md5", "anon", "adh", "aecdh", "psk", "srp", "idea", "seed", "camellia"} |
| 402 | |
| 403 | // weakCiphers returns the offending tokens in an OpenSSL cipher list. "des" |
| 404 | // also matches 3DES suites, which is intended: both are legacy. |
| 405 | func weakCiphers(list string) []string { |
| 406 | var out []string |
| 407 | for _, tok := range strings.Split(strings.ToLower(list), ":") { |
| 408 | tok = strings.TrimSpace(tok) |
| 409 | if tok == "" || strings.HasPrefix(tok, "!") || strings.HasPrefix(tok, "-") { |
| 410 | continue |
| 411 | } |
| 412 | if tok == "all" || tok == "default" || tok == "complementofdefault" || tok == "complementofall" { |
| 413 | out = append(out, tok) |
| 414 | continue |
| 415 | } |
| 416 | for _, w := range weakCipherTokens { |
| 417 | if strings.Contains(tok, w) { |
| 418 | out = append(out, tok) |
| 419 | break |
| 420 | } |
| 421 | } |
| 422 | } |
| 423 | return out |
| 424 | } |
| 425 | |
| 426 | // weakProtocolLines returns the directive lines (ssl_protocols or |
| 427 | // SSLProtocol) that still allow a version below TLS 1.2, rendered as |
| 428 | // evidence. |
| 429 | func weakProtocolLines(c serverConf, key string) []string { |
| 430 | var ev []string |
| 431 | for _, l := range c.directive(key) { |
| 432 | if w := weakTLSProtocols(l.Value); len(w) > 0 { |
| 433 | ev = append(ev, l.File+": "+l.Raw+" (allows "+strings.Join(w, ", ")+")") |
| 434 | } |
| 435 | } |
| 436 | return ev |
| 437 | } |
| 438 | |
| 439 | // floorOverrides reports whether the system crypto library floor already |
| 440 | // refuses TLS 1.0 and 1.1, so a permissive directive has no effect. |
| 441 | func floorOverrides(c serverConf) bool { return c.tlsFloor.Known && c.tlsFloor.Forbids } |
| 442 | |
| 443 | const floorPassMsg = "The directive still lists TLS 1.0 or 1.1, but the system crypto library floor refuses them, so they cannot be negotiated; tidy the directive so the configuration says what it does" |
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-APA-010 Apache Serves Repository and Environment Files (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/WEB-APA-010.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_apa_010,
author = {{OpenVAPT}},
title = {{WEB-APA-010 Apache Serves Repository and Environment Files}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/WEB-APA-010.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.