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
LowWEB-APA-001Information ExposureWeb Apps

Apache Reveals Version in Header and Footer

ServerTokens is not Prod or ServerSignature is On, so responses and error pages show the Apache version and modules.

Why It Matters
The Server header and the signature line on error pages tell an attacker the exact release and loaded modules. Hiding them removes a free hint; patching remains the real fix.
How It Detects
Reads the Apache configuration tree for ServerTokens and ServerSignature. Fails when ServerTokens is absent (default Full) or not Prod, or when ServerSignature is On or EMail.
How to Fix
Set ServerTokens Prod and ServerSignature Off in apache2.conf or conf-enabled/security.conf (Debian) or httpd.conf (RHEL), then apachectl configtest and reload.
How to Verify
curl -sI https://<site>/ | grep -i '^server:' should print Server: Apache with nothing after it.
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
16.7
NIST CSF 2.0 Subcategory
PR.PS-01
OWASP WSTG v4.2
WSTG-INFO-02
CWE
CWE-497
OWASP Top 10
A05:2021
OWASP ASVS 4.0.3
14.3.3
NIST SP 800-53 Rev. 5
CM-6, SI-11
Benchmark Topic
Web server banner and version disclosure (topic only, no benchmark text)
References
Apache HTTP Server, ServerTokens directive
OWASP WSTG-INFO-02, Fingerprint Web Server
Learn the theory behind this check: P18 Web Server Hardening: nginx and Apache.

Source Code

The complete file that defines this check. Run it with openvapt scan --only WEB-APA-001, or read it offline with openvapt explain WEB-APA-001.

internal/checks/web/serverconf.go · Lines 1-443View on GitHub at This Commit
1package web
2
3import (
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.
18type 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
33type 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
40type 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
48var 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
56var 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.
67func 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
94func 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
103func 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).
134func 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.
159func (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
194func (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
206func 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
215func 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.
228func (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.
240func (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.
250func (c serverConf) anyValue(key string) bool { return len(c.directive(key)) > 0 }
251
252// evidenceFor renders the matching lines as "file: line" evidence.
253func (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.
266func (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.
285type 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
314func 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.
369func 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.
401var 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.
405func 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.
429func 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.
441func floorOverrides(c serverConf) bool { return c.tlsFloor.Known && c.tlsFloor.Forbids }
442
443const 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.

APA 7
OpenVAPT. (2026). WEB-APA-001 Apache Reveals Version in Header and Footer (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/WEB-APA-001.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_web_apa_001,
  author       = {{OpenVAPT}},
  title        = {{WEB-APA-001 Apache Reveals Version in Header and Footer}},
  year         = {2026},
  howpublished = {\url{https://openvapt.com/checks/WEB-APA-001.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.