Web Server security checklist

Web Server Content Exposure Checklist: 6 Checks (2026)

Information disclosure does not break anything. It hands an attacker a map. This page documents all 6 checks in the CtrlOps Content Exposure audit: what each one reads, the exact value that passes, the values that warn or fail, and the config that closes it.

Hiren KalariyaLast reviewed: Aug 22, 202615 min read
6
checks in this audit
1
rated high severity
14s
automated run time
2
need root or sudo

Every pass, warning and failure threshold on this page is transcribed from the web-server-security-content-exposure audit script that CtrlOps runs, and a build check fails if the two ever disagree. See how the audit runs.

An exposed .env file is a database password, a mail credential and an API key in a single GET request. An open directory listing is a map of everything you never linked. A stock welcome page is a free statement of which software you run. None of it breaks the site, which is exactly why it survives. This page documents the 6 checks in the CtrlOps Content Exposure audit, in the order the audit runs them.

Every threshold below is transcribed from the audit script itself. The distinguishing feature of this audit is that it probes rather than guesses: it requests the paths in question over HTTP and grades the status code, then falls back to what is on disk and in the config.

Key takeaways

Six checks, and the grading is built around one distinction: what is reachable right now versus what is one config change away from being reachable.

  • One is rated HIGH: hidden file exposure, which is the one that hands over credentials. Four are MEDIUM and one is LOW.
  • Two need root: the dotfile check and the backup file check, because both walk document roots that are not world-readable.
  • Two checks cannot fail. Status endpoints and default pages warn at most, because both have legitimate configurations that look identical from outside.
  • Served beats configured. Checks 1, 2 and 4 all reserve FAIL for the case where the audit actually got the bad response, and drop to WARN when the config allows it but the probe did not reproduce it.
  • The whole audit takes about 14 seconds when CtrlOps runs it over your existing SSH connection. It is the longest web server audit, because it makes real HTTP requests.
#CheckSeverityWhat it catchesRoot needed
1Directory listingMEDIUMBrowsable file treeNo
2Hidden file exposureHIGH.git history or .env downloadableYes
3Backup filesMEDIUMEditor and deploy leftovers served as textYes
4HTTP methodsMEDIUMTRACE answering, write methods advertisedNo
5Status endpointsMEDIUMLive request data with no access ruleNo
6Default pagesLOWDistribution welcome page still liveNo
The probe is local, and that limits what it proves. The audit curls loopback with your configured server_name as the Host header. That is the right way to reach your real vhost from the server itself, and it means a path blocked at a CDN or an external firewall still shows as reachable here. Read a finding on this page as "the origin serves this", which is the layer you control directly.

Check 1: Is directory listing enabled anywhere?

Medium severityNo sudo needed

With directory listing enabled, a request for a path with no index file returns a browsable index of everything in it. That turns any directory you forgot to link into a discoverable one: old exports, a test/ folder, the PDF a client sent last year.

The audit counts autoindex on in the Nginx config and Options ... Indexes in Apache, excluding -Indexes, then checks the live response body for a rendered listing. The two signals together decide the result.

How to check manually

grep -rn 'autoindex' /etc/nginx/ | grep -v '#'
grep -rn 'Options.*Indexes' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'

curl -s http://127.0.0.1/ | grep -iE '<title>Index of|<h1>Index of'
Result thresholds this check applies
ResultWhenWhat it means
PASSNeither autoindex on nor Options Indexes is presentDirectory listing is disabled.
WARNThe config enables listing but the probe saw no listingWhich server and how many directives are named. An index file is masking it today, and one missing index.html exposes the tree.
FAILThe config enables listing and the server is serving one right nowVisitors can browse the tree and find files you never linked.
SKIPNo web server detected on the hostNothing to evaluate.

The warning branch exists because Debian ships Options Indexes for /var/www by default, and on most hosts every directory has an index file, so nothing is ever listed. That is a real finding, just not an active one: the protection is an index file rather than a setting.

How to fix it

autoindex off;
<Directory /var/www/>
    Options -Indexes +FollowSymLinks
</Directory>

Then sudo nginx -t && sudo systemctl reload nginx, or sudo apachectl configtest && sudo systemctl reload apache2.

Turning listing off does not protect the files. Anyone who already knows or guesses a filename can still download it. Listing only controls discovery, so treat it as one of two fixes: disable the index, and move anything sensitive out of the document root entirely.

Check 2: Are .git or .env reachable over HTTP?

High severityNeeds sudo

An exposed .env gives up every credential the application uses. An exposed .git directory gives up the entire source history, including the credentials somebody committed once and removed in the next commit. Automated scanners request both on every host they touch, which is why this is the only HIGH severity check in this audit.

The audit does two things: it looks for .git and .env inside the document roots on disk, and it requests /.git/HEAD and /.env over HTTP and reads the status code. Then it checks whether the config denies dotfile requests at all.

How to check manually

# Is it served
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/.git/HEAD
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/.env

# Is it on disk
sudo ls -la /var/www/html/.git /var/www/html/.env 2>/dev/null

# Does the config deny dotfiles
grep -rnE 'location[[:space:]]*~[^{]*/\\\.' /etc/nginx/ 2>/dev/null | grep -v '#'
grep -rnE 'FilesMatch[[:space:]]*"\^\\\.' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
Result thresholds this check applies
ResultWhenWhat it means
PASSNeither path is served and neither is in the web rootTwo distinct passes. One says the config also denies dotfile requests; the other says nothing was found but suggests adding an explicit deny rule anyway.
WARNThe files exist in the web root but the probe did not get themThe paths found on disk are named. One config change exposes them, so move them out of the web root.
FAILEither path answered 200 over HTTPThe exact path is named. Source history or credentials are downloadable right now.
SKIPNo web server detected on the hostNothing to evaluate.

Note the scope: the check looks for .git and .env specifically, not every dotfile. Those two cover the overwhelming majority of real incidents, and a broad dotfile sweep produces noise from .well-known and editor metadata that buries the finding that matters.

How to fix it

location ~ /\. {
    deny all;
    return 404;
}

# ACME validation has to stay reachable
location ^~ /.well-known/ {
    allow all;
}
<FilesMatch "^\.">
    Require all denied
</FilesMatch>

<Directory "/var/www/html/.well-known">
    Require all granted
</Directory>

The deny rule is the safety net. The actual fix is that .git and .env should not be inside the document root in the first place: deploy from a build artifact, or point the root at a public/ subdirectory so the repository and the environment file sit above it.

A 404 on /.env is not proof you are safe. The file may be one directory up, reachable at a path the probe did not try, or served by a different vhost. If this check warns because the files exist on disk, move them, rather than relying on the deny rule holding through every future config change.

Check 3: Are backup or archive files sitting in the web root?

Medium severityNeeds sudo

wp-config.php.bak is your database password served as plain text, because the server has no handler for .bak and falls back to sending the bytes. These files arrive from editors writing swap and backup copies, from deploy scripts taking a safety copy, and from somebody duplicating a config before an edit.

The audit searches the document roots to a depth of three and splits the result in two. Editor and dump leftovers are a failure. Archives are a warning, because a .zip in a downloads directory is frequently deliberate.

How to check manually

sudo find /var/www -maxdepth 3 -type f \
  \( -name '*.bak' -o -name '*.old' -o -name '*.save' -o -name '*.sql' -o -name '*~' -o -name '*.orig' \) 2>/dev/null

sudo find /var/www -maxdepth 3 -type f \
  \( -name '*.zip' -o -name '*.tar.gz' -o -name '*.tgz' \) 2>/dev/null
Result thresholds this check applies
ResultWhenWhat it means
PASSNo backup, dump or archive files in the document rootsNothing matched either pattern set.
WARNOnly archives were found: zip, tar.gz or tgzUp to five are named. Fine if they are intentional downloads, otherwise they are a full-source leak.
FAILEditor or dump leftovers were found: bak, old, save, sql, orig or tilde filesUp to five are named. These are never meant to be served and often contain credentials or full source.
SKIPNo document root was identified in the configNothing to evaluate.

The split matters when you read the report. A failure here is unambiguous: nothing generates a .bak on purpose in a served directory. A warning needs your judgement, because a release tarball published for download looks the same to find as a full source archive somebody forgot.

How to fix it

# Review first, then delete
sudo find /var/www -maxdepth 3 -type f \( -name '*.bak' -o -name '*~' -o -name '*.save' \) -print
sudo find /var/www -maxdepth 3 -type f \( -name '*.bak' -o -name '*~' -o -name '*.save' \) -delete

Then add the safety net, so the next one is not served while you wait to notice it:

location ~* \.(bak|old|orig|save|swp|tmp|dist|sql)$ {
    deny all;
    return 404;
}
<FilesMatch "\.(bak|old|orig|save|swp|tmp|dist|sql)$">
    Require all denied
</FilesMatch>
Bottom line: blocking the extensions is the net, removing the files is the fix. The real prevention is deploying from a build artifact rather than editing in place on the server, because an editor that never opens a file on production never leaves a swap file there.

Check 4: Are dangerous HTTP methods answered?

Medium severityNo sudo needed

TRACE echoes the request back to the client, headers included, which is the mechanism behind cross-site tracing: a script that cannot read an HttpOnly cookie directly can sometimes get the server to read it back. PUT and DELETE handled at the web server layer let a client write or remove files outright.

The audit sends a TRACE request and reads the status, sends an OPTIONS request and reads the Allow header, and checks whether Apache has an explicit TraceEnable off.

How to check manually

curl -s -o /dev/null -w '%{http_code}\n' -X TRACE http://127.0.0.1/
curl -sI -X OPTIONS http://127.0.0.1/ | grep -i '^allow:'
grep -rni 'TraceEnable' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
Result thresholds this check applies
ResultWhenWhat it means
PASSTRACE is not answered and no write methods are advertisedNothing to change.
WARNThe Allow header advertises PUT, DELETE or TRACE, or Apache has no explicit TraceEnable offTwo distinct warnings. The first names the advertised methods. The second is a configuration gap rather than an observed behaviour, and it exists because the Apache default has changed between versions.
FAILTRACE responded 200Cross-Site Tracing can read headers a script should never see. Set TraceEnable off.
SKIPNo web server detected on the hostNothing to evaluate.

The Allow header is read from an OPTIONS request specifically, because a normal GET does not return one. If your application answers OPTIONS itself, for CORS preflight, what you see here is the application's answer rather than the web server's.

How to fix it

# Global, outside any vhost
TraceEnable off
# nginx never implements TRACE, but you can narrow the method set
location / {
    limit_except GET HEAD POST {
        deny all;
    }
    proxy_pass http://app;
}

If your API genuinely needs PUT, PATCH and DELETE, handle them in the application and leave the web server passing them through. The finding is about methods the web server itself answers, not about your REST routes.

Bottom line: TraceEnable off is a one-line change with no legitimate cost. Nothing in a modern stack uses TRACE, PCI DSS and OWASP have both called for disabling it for years, and the Apache default has been different enough across versions that stating it explicitly is worth the line.

Check 5: Are status endpoints reachable without a restriction?

Medium severityNo sudo needed

Apache's /server-status shows every request currently being processed, complete with URLs and query strings. /server-info dumps the effective configuration. Nginx's stub_status is tamer but still reports connection counts. All three are useful to you and to anyone doing reconnaissance.

The audit requests /server-status, /server-info, /nginx_status and /status, then checks whether the config contains any access restriction: a Require local, a Require ip, an allow 127.0.0.1 or a deny all.

How to check manually

for p in /server-status /server-info /nginx_status /status; do
  printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1$p)"
done

grep -rniE 'Require[[:space:]]+(local|ip)|allow[[:space:]]+127\.0\.0\.1|deny[[:space:]]+all' \
  /etc/nginx/ /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
Result thresholds this check applies
ResultWhenWhat it means
PASSEndpoints answered but the config restricts them, a status handler is configured with restrictions, or no status handler exists at allThree distinct passes. The first is the important one: a 200 from localhost is what Require local is supposed to do, so it is evidence the handler exists rather than that it is exposed.
WARNEndpoints answered with no restriction in the config, or a status handler is configured with no visible restrictionTwo distinct warnings, and the endpoints found are named. Scope them to localhost.
SKIPNo web server detected on the hostNothing to evaluate.

This check has no failure branch, and the reason is the probe's position. It runs on the server, so it is always the local client that Require local is designed to permit. A 200 here proves the endpoint exists; only the absence of an access rule tells you anyone else could reach it.

How to fix it

<Location "/server-status">
    SetHandler server-status
    Require ip 127.0.0.1 ::1
    # Require ip 10.0.0.0/8
</Location>
location = /nginx_status {
    stub_status;
    allow 127.0.0.1;
    allow ::1;
    deny all;
}

Keep the endpoint. Prometheus, Netdata and every uptime tool you might add later will want it. Scope it to loopback and your monitoring subnet, and confirm from outside the host that it is refused.

Verify from off the box. This check cannot distinguish a properly scoped endpoint from an open one by response code alone, because it is always the allowed client. Run curl -I https://yourdomain.com/server-status from your laptop: a 403 or 404 is what you want to see.

Check 6: Is the stock welcome page still being served?

Low severityNo sudo needed

"Welcome to nginx!" and the Apache2 Ubuntu Default Page tell a scanner which server and which distribution family this host runs, before anything else is tried. On their own they are low value. Combined with a leaked version number they narrow a CVE search to a single result.

The more practical problem is what a live default page means: an unmatched hostname is landing on the distribution vhost, which is usually a sign that a site's server block is not matching the way its author expected.

The audit looks for the stock page text in the live response body, then falls back to checking whether Apache's 000-default.conf is still enabled.

How to check manually

curl -s http://127.0.0.1/ | grep -iE 'Welcome to nginx|Apache2 (Ubuntu|Debian) Default Page|It works!|Test Page for the'

ls -la /etc/apache2/sites-enabled/000-default.conf 2>/dev/null
ls -la /etc/nginx/sites-enabled/default 2>/dev/null
Result thresholds this check applies
ResultWhenWhat it means
PASSNo stock welcome page in the response and no distribution default vhost enabledNothing to change.
WARNThe stock page is being served, or the default vhost is still enabledTwo distinct warnings. The first means an unmatched request is landing on it right now. The second is the latent version: the vhost is enabled but something else is answering.
SKIPNo web server detected on the hostNothing to evaluate.

How to fix it

sudo a2dissite 000-default
sudo systemctl reload apache2

# nginx
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

Better than removing it, replace it with a catch-all that refuses unmatched hostnames:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444;
}

That closes the connection without a response, which also stops your server answering for domains somebody has pointed at your IP without asking.

Bottom line: this is LOW severity as an information leak and more interesting as a symptom. A live default page usually means a vhost is not matching, and the request you are watching land on the stock page was meant for a site that is not getting it.

What this audit does not cover

This audit reads what the server exposes over HTTP and what sits in the document roots. It does not check:

  • HTTP response headers. HSTS, CSP, frame protection and cookie flags are the Security Headers audit.
  • TLS. Protocol versions, ciphers, certificate expiry and the HTTPS redirect are the TLS Configuration audit.
  • Permissions and limits. Config file modes, web root ownership, request caps and rate limiting are the Configuration Hardening audit.
  • Version disclosure. The Server header and pending patches are the Identification and Patching audit.
  • Every dotfile and every path. The probe checks .git and .env at the document root, four status endpoints and the root page. It is a sweep for the paths that are actually attacked, not a crawl of your site.
  • What a CDN blocks. The probe runs on the server, so anything your edge refuses still shows as reachable here.

The manual audit problem at scale

Doing this by hand means grepping config directories, running a find across every web root, curling half a dozen paths, and remembering that TRACE needs -X TRACE and Allow only comes back on OPTIONS. On one server it is fifteen to twenty minutes. On ten it is most of a morning.

The part that goes wrong is the vhost again. Curling 127.0.0.1 lands on the default server block, so a manual check of /.env can return 404 from a vhost that is not the one hosting the application, while the real site serves it happily.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 6 checksAbout 2 hoursAbout 2 minutes
Path probingCurl each path per hostProbed with the right Host header automatically
Web root discoveryGrep for root and DocumentRootExtracted from the effective config
Get a scored reportNot availableAutomatic
Re-audit after fixingAnother 2 hoursAnother 2 minutes

How CtrlOps runs all 6 checks in one click

Instead of running these commands on every server by hand, CtrlOps runs the whole Content Exposure audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.

Watch it run

The recording below walks through the SSH and Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal.

Step 1: Open the Audit tab

Connect to your server in CtrlOps and open the Audit tab in the left sidebar.

Step 2: Select Content Exposure

Choose it from the Web Server category. All 6 checks are listed with their descriptions, severity levels and the estimated run time, about 14 seconds in total. Two of them need root, and the UI says so before you run anything.

Step 3: Run it

The audit runs over your existing SSH session. Results stream in as they complete. This audit makes real HTTP requests to loopback, which is why it takes a few seconds longer than the others in the category.

Step 4: Read the report

You get a summary containing:

  • A hardening score out of 100
  • A severity breakdown: how many HIGH, MEDIUM and LOW findings
  • Pass, warning, failed and skipped counts
  • A findings table naming the exact paths, files and endpoints it found

Step 5: Fix what failed

Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters here because the fix for check 3 is a find -delete across your web root, and that is a command worth reading twice.

Step 6: Re-run and compare

Run the same audit again after reloading and watch the score move. This is the audit to run after every deployment: a .bak file, a left-behind archive and a newly enabled default vhost all arrive through ordinary release work rather than through mistakes.

Conclusion

Every finding on this page is something the server is willing to hand out to anyone who asks for it. None of them is an exploit on its own. Together they are the reconnaissance phase of somebody else's attack, done for them and cached.

One check here is HIGH severity, and it is the one worth running today: a reachable .git or .env is not a step towards compromise, it is the credentials. The CtrlOps Security Audit runs all 6 in about 14 seconds per server, probing the real vhost rather than the default one.

The neighbouring checklists cover the other web server layers: security headers, TLS configuration, configuration hardening, identification and patching and logging and monitoring.


Frequently asked questions

It is any case where the server volunteers internal detail to an unauthenticated visitor: a browsable directory tree, a downloadable .env or .git, a backup file served as text, a live status page, or a default page naming the software. None of it is an exploit. All of it shortens the work of finding one.

On Nginx, set autoindex off; in the relevant block, then sudo nginx -t && sudo systemctl reload nginx. On Apache, use Options -Indexes in the <Directory> block, then sudo apachectl configtest && sudo systemctl reload apache2. Verify by requesting a directory that has no index file: it should return 403 rather than a listing.

Request it: curl -s -o /dev/null -w '%{http_code}' https://yourdomain.com/.env. A 200 means it is downloadable right now. Also check whether it exists at all under the document root with sudo ls -la /var/www/html/.env, because a file that is present but currently blocked is one config change from being served.

Because those two are what automated scanners request on every host, and they are the two whose contents are immediately useful: full source history and a complete credential set. A broad sweep for every dotfile produces noise from .well-known and editor metadata, and the finding that matters gets lost in it.

TRACE reflects the request back to the client, including headers. Combined with a scripting flaw, that historically let an attacker read headers the browser would not expose to script, including cookies marked HttpOnly. Nothing in a modern application uses it, so TraceEnable off on Apache is free. Nginx never implemented TRACE at all.

Yes, to a depth of three inside every document root it found in the config. It splits the result: editor and dump leftovers such as .bak, .old, .save, .sql, .orig and tilde files are a failure, while .zip, .tar.gz and .tgz archives are a warning, because those are routinely published on purpose from a downloads directory.

Because the probe runs on the server itself, and a properly configured Require local is supposed to answer a local client with a 200. Response code alone cannot distinguish a correctly scoped endpoint from an open one, so the audit warns only when it finds no access restriction in the config at all. Verify from outside the host to be certain.

Worker state, uptime, request rates, and the full URL of every request currently in flight, including query strings. That last part is the problem: admin paths, API endpoints and any token that travels in a URL are all visible to whoever loads the page. Restrict it to loopback and your monitoring subnet rather than removing it.

Two reasons. It confirms the software and often the distribution to a scanner, which narrows a CVE search. More practically, a live default page means an unmatched hostname is landing on the distribution vhost, which usually indicates that a real site's server block is not matching the way its author intended.

On hosts with no web server, and on containerised deployments where the config is baked into an image: scan the image in CI instead, because fixing the running container does not change the next deploy. It is also origin-only, so anything your CDN blocks still appears reachable here.

After every deployment, and monthly regardless. Checks 3 and 6 track things that arrive through ordinary release work: an editor swap file, a forgotten archive, a default vhost re-enabled by a package upgrade. Check 2 is worth running on a schedule rather than after changes, because an exposed .env is the one finding where hours matter.

Audit your fleet

Run all these checks on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

Windows

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals