There is one line in a great many Node projects, including mine, that quietly publishes the entire application to the internet:

app.use(express.static(__dirname));

It is the obvious thing to write. Your HTML, CSS and images live in the project root, and this serves them. It works immediately, which is exactly why nobody looks at it again.

What it actually says is: serve every file in this directory. Not the ones you meant. All of them.

Try it on your own site

Take whatever is deployed right now and ask for a file you never intended to expose:

curl -s https://your-site.example/server.js | head -n 20
curl -s https://your-site.example/package.json

If you get source code back, so does everyone else. The uncomfortable part is how ordinary the list of exposed files usually is:

  • server.js — your entire backend, including every route, every validation rule, and every comment explaining what you were unsure about.
  • package.json and package-lock.json — exact versions of every dependency, which is a shopping list for anyone matching your stack against known vulnerabilities.
  • Log filesstderr.log tends to contain stack traces with absolute file paths, and often request details that were never meant to leave the machine.
  • Data files — anything the app persists to JSON on disk. For CodX Editor that was published-projects.json, which is every published project in one file.
  • Confignodemon.json, build configs, anything describing your deployment.

None of this is an Express bug. It is doing precisely what it was asked. The bug is that "the folder my app lives in" and "the folder the public should be able to download" were treated as the same folder, and nothing in the code ever said otherwise.

The two options that look like protection and are not

express.static takes options, and two of them get mistaken for security.

app.use(express.static(__dirname, {
  dotfiles: "ignore",
  extensions: ["html"]
}));

dotfiles: "ignore" hides files whose names begin with a dot. That covers .env and .git, which is genuinely worth having — but it is a rule about the first character of a filename, not about sensitivity. server.js does not start with a dot.

extensions: ["html"] is a convenience feature for clean URLs: a request for /about will find about.html. It adds a fallback. It removes nothing. Every file that was reachable before is still reachable.

The right fix, and the fix you can ship today

The correct answer is structural. Put public files in their own directory and serve only that:

app.use(express.static(path.join(__dirname, "public")));

Now the default is private and exposure is opt-in. A file is only public if you deliberately put it in public/. New files you add next year are safe without you thinking about it, which is the property you actually want — security that does not depend on remembering.

On a project that is already live, that move is not free. Every asset path in every HTML file, every route that reads from disk, every deployment script has to agree about the new layout. On a large single-page editor that is a genuinely risky afternoon.

So the retrofit is a denylist that runs before the static handler:

const PRIVATE_STATIC_PATHS = new Set([
  "/package.json",
  "/package-lock.json",
  "/published-projects.json",
  "/.codx-sync-blobs.json",
  "/README.md",
  "/server.js",
  "/stderr.log",
  "/stdout.log",
  "/nodemon.json",
]);

app.use((req, res, next) => {
  let requestPath = "";
  try {
    requestPath = decodeURIComponent(String(req.path || ""));
  } catch {
    return res.status(400).type("text/plain").send("Invalid request path.");
  }

  if (
    PRIVATE_STATIC_PATHS.has(requestPath) ||
    requestPath === "/node_modules" ||
    requestPath.startsWith("/node_modules/")
  ) {
    return res.status(404).type("text/plain").send("Not found.");
  }

  next();
});

I want to be honest about what that is. A denylist is a list of the mistakes you have thought of. It protects the nine files above and nothing else, and every future file starts out public. It is the right emergency measure and the wrong permanent design.

Three details in there that are not decoration

decodeURIComponent, inside a try

Without decoding, a request for /server%2Ejs arrives with the path still encoded, does not match the string /server.js, and sails through to the static handler — which decodes it and serves the file. A denylist that compares raw strings is trivially bypassed by encoding one character.

The try matters too, because decodeURIComponent throws on malformed input like /%E0%A4%A. An uncaught throw in middleware is a 500, and a route that reliably 500s on a crafted URL is its own small problem.

404, not 403

403 Forbidden confirms the file exists. 404 Not Found says nothing. When somebody is probing paths to work out what you are running, the difference between "no" and "yes, but you can't" is real information.

node_modules is checked separately

It cannot go in the set, because the set holds exact paths and the entire subtree needs blocking. It is worth blocking explicitly rather than assuming it does not matter: dependency directories contain test fixtures, example servers, and occasionally credentials that a package author left in a snapshot.

What to take from this

  • Check before you assume. One curl at your own /server.js answers this in five seconds.
  • express.static(__dirname) means "publish this whole project". If your app root and your web root are the same directory, they should not be.
  • dotfiles and extensions are not access control. One is a filename rule, the other is a convenience.
  • Decode the path before you compare it, and wrap the decode, or your denylist is one %2E away from useless.
  • A denylist buys you time. A public/ directory is the actual fix, and it is worth scheduling rather than admiring the workaround.