Codetail

Article 7 of 12

Security Misconfiguration

Nothing was exploited. The defaults just did the attacker's job for them.

20 min read

Verbose errors: a stack trace is documentation for an attacker

Nothing in this article involves an attacker finding a clever bug. Every one of these is a setting that was fine in development, correct even, and just never got flipped back before the app went live.

A Flask app started the way every tutorial starts it

PythonVulnerable
1app.run(debug=True)

Debug mode is genuinely useful while you're writing the app: an unhandled exception shows a full stack trace, the exact line and variable values that caused it, and in Flask's case an interactive console you can type Python into, right in the browser. In production, that same feature hands an attacker your file paths, your framework version, the names of internal functions and variables, and sometimes a live code execution console sitting behind nothing but a URL.

None of that requires finding a bug first. An attacker just needs to trigger any unhandled exception, an unexpected input type, a missing field, a malformed request, and the app does the reconnaissance for them.

Fixed: debug tooling stays on developer machines

PythonFixed
1app.run(debug=os.environ.get("FLASK_ENV") == "development")
2
3@app.errorhandler(500)
4def internal_error(e):
5 log_exception(e) # full detail, server-side only
6 return {"error": "Something went wrong"}, 500

The user gets a generic message. The full trace still exists, it goes to your logs or error tracker where you can actually use it, instead of to whoever happened to send the request that triggered it. Django has a related trap: DEBUG = True with an empty ALLOWED_HOSTS quietly falls back to accepting only localhost, so a team can run the app locally for months without ever noticing that ALLOWED_HOSTS was never actually configured for the real production domain. Set both deliberately before anything goes live, not just one.

Default credentials: someone is already scanning for these

A seed script that creates a demo admin account for local development, or an internal service that ships with no password because "it's only for internal use," is completely reasonable until the thing it's attached to becomes reachable from the open internet.

A Redis instance, configured the way the default config file ships

BashVulnerable
1bind 0.0.0.0
2# requirepass is commented out by default

Redis with no password, bound to every network interface, is one port scan away from anyone on the internet, not a targeted attacker, an automated scanner running against every IPv4 address, finding it. Services like Shodan exist specifically to index exposed infrastructure like this, and unauthenticated Redis, MongoDB, and Elasticsearch instances are a permanent fixture on that list, discovered in minutes of going live, not months.

Fixed: authentication required, and bound to a private network

BashFixed
1bind 127.0.0.1 10.0.4.12
2requirepass a-real-generated-password

The same story plays out at the application layer: a demo account seeded as admin / admin123 for local testing, meant to be deleted before launch, that survives because the cleanup step was a manual note in a README instead of something the deploy process enforced.

In practice:don't ship a default credential at all, even a temporary one. Force a real password on first run, generate one randomly and print it once, or require setup through a flow that can't be skipped. A default that has to be manually remembered and manually removed will eventually reach production, on some deploy, by some team, no exception.

CORS: the setting that can quietly undo your CSRF defenses

The CSRF article in this series mentioned that a JSON API gets partial protection almost for free, a plain HTML form can't set Content-Type: application/json on its own, and cross-origin JavaScript that tries to needs your server's permission via CORS. This section is the part where that permission gets handed out too freely.

A CORS middleware that looks permissive on purpose, for convenience

PythonVulnerable
1@app.after_request
2def add_cors_headers(response):
3 response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*")
4 response.headers["Access-Control-Allow-Credentials"] = "true"
5 return response

Browsers reject a literal Access-Control-Allow-Origin: * when credentials are involved, which is exactly why this code doesn't send a literal wildcard. It reflects back whatever Origin header the request happened to send, satisfying the letter of that rule while granting every origin on the internet the same access a wildcard would have. Paired with Access-Control-Allow-Credentials: true, this means evil.example can make an authenticated fetch to your API, with the victim's cookies attached, and actually read the JSON response back. CSRF at least required guessing blind. This lets the attacker see the data.

Fixed: an explicit allowlist, not a reflection

PythonFixed
1ALLOWED_ORIGINS = {"https://app.example", "https://admin.example"}
2
3@app.after_request
4def add_cors_headers(response):
5 origin = request.headers.get("Origin")
6 if origin in ALLOWED_ORIGINS:
7 response.headers["Access-Control-Allow-Origin"] = origin
8 response.headers["Access-Control-Allow-Credentials"] = "true"
9 return response

Now the origin has to match something on a list you control, not something the request supplied. If your API genuinely needs to be called from arbitrary origins, that's a real, valid use case, just don't pair it with credentials. A public, unauthenticated endpoint can reasonably allow any origin. One that reads a session cookie should never allow more origins than you can actually name.

Exposed storage and leftover endpoints

Cloud storage defaults have gotten safer over the years, largely because so many breaches turned out to be nothing more than a bucket policy set to public read.

A bucket policy, set once through a console checkbox and never revisited

JSONVulnerable
1{
2 "Statement": [{
3 "Effect": "Allow",
4 "Principal": "*",
5 "Action": "s3:GetObject",
6 "Resource": "arn:aws:s3:::user-uploads/*"
7 }]
8}

This is a reasonable policy for a bucket serving public marketing assets. It's a breach waiting to be discovered for one named user-uploads, where the objects are ID documents, contracts, or private photos. Nobody needs to guess a filename to find these either, bucket listing is often left enabled alongside public read, so an attacker gets a directory of every file, not just the ones they happen to know the name of.

Fixed: private by default, access mediated through your own app

JSONFixed
1{
2 "Statement": [{
3 "Effect": "Deny",
4 "Principal": "*",
5 "Action": "s3:GetObject",
6 "Resource": "arn:aws:s3:::user-uploads/*",
7 "Condition": { "Bool": { "aws:PrincipalIsAWSService": "false" } }
8 }]
9}

Serve files through your application, which checks that the requesting user actually owns the object, and hand out short-lived signed URLs when a browser needs direct access. The bucket itself should never be the thing standing between a private file and the public internet.

Deploying the repo, not the build

A surprising number of production incidents start with example.com/.git/config returning a real file. It happens when a deploy script copies the whole checked-out repository, source, tests, and the .git directory included, onto a server that then serves that directory as static files. Tools that reconstruct an entire commit history from an exposed .git/objects folder are a normal part of any penetration tester's toolkit, and they don't need anything beyond that one exposed directory to pull your full source history, including anything ever committed and later "removed."

The durable fix isn't a rule that blocks /.git/ at the web server, useful, but one misconfigured route away from being forgotten again. It's deploying only the built artifact, whatever your framework actually needs to run, never the repository directory itself. Nothing to expose if it was never on the server in the first place.