CVE-2024-24756 (JavaScript, published 2024-02-01T23:15Z)
Description: value: Crafatar serves Minecraft avatars based on the skin for use in external applications. Files outside of the `lib/public/` directory can be requested from the server. Instances running behind Cloudflare (including crafatar.com) are not affected. Instances using the Docker container as shown in the README are affected, but only files within the container can be read. By default, all of the files within the container can also be found in this repository and are not confidential. This vulnerability is patched in 2.1.5.
Severity: nan (CVSS v3 base score: 7.5)
Weakness: CWE-22 \u2014 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
Repository: https://github.com/crafatar/crafatar
Files changed:
lib/routes/avatars.js
lib/routes/capes.js
lib/routes/renders.js
lib/routes/skins.js
lib/server.js
test/test.js
Commit message: improve URL parsing

uses `new URL()` and `decodeURI()` instead of `url.parse()`
also checks that the requested file is in a subdirectory of `public/` before serving the file

fixes path traversal vulnerability GHSA-5cxq-25mp-q5f2


Vulnerable code:
      var parsed = req.url;
      delete parsed.query.default;
      delete parsed.search;
      parsed.path_list[1] = def;
      parsed.pathname = "/" + parsed.path_list.join("/");
      var newUrl = url.format(parsed);
  var size = parseInt(req.url.query.size) || config.avatars.default_size;
  var def = req.url.query.default;
  var overlay = Object.prototype.hasOwnProperty.call(req.url.query, "overlay") || Object.prototype.hasOwnProperty.call(req.url.query, "helm");
  var def = req.url.query.default;
      var parsed = req.url;
      delete parsed.query.default;
      delete parsed.search;
      parsed.path_list[2] = def;
      parsed.pathname = "/" + parsed.path_list.join("/");
      var newUrl = url.format(parsed);
  var def = req.url.query.default;
  var scale = parseInt(req.url.query.scale) || config.renders.default_scale;
  var overlay = Object.prototype.hasOwnProperty.call(req.url.query, "overlay") || Object.prototype.hasOwnProperty.call(req.url.query, "helm");
      var parsed = req.url;
      delete parsed.query.default;
      delete parsed.search;
      parsed.path_list[1] = def;
      parsed.pathname = "/" + parsed.path_list.join("/");
      var newUrl = url.format(parsed);
  var def = req.url.query.default;
  var filename = path.join(__dirname, "public", req.url.path_list.join("/"));
  fs.access(filename, function(fs_err) {
    if (!fs_err) {
      fs.readFile(filename, function(err, data) {
          body: data,
          type: mime.getType(filename),
          err: err,
      });
    } else {
      callback({
        body: "Not found",
        status: -2,
        code: 404,
      });
    }
  });
// splits a URL path into an Array
// the path is resolved and decoded
  // remove double and trailing slashes
  pathname = pathname.replace(/\/\/+/g, "/").replace(/(.)\/$/, "$1");
  for (var i = 0; i < list.length; i++) {
    // URL decode
    list[i] = querystring.unescape(list[i]);
  }
  req.url = url.parse(req.url, true);
  req.url.query = req.url.query || {};

        redirect: "/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
        redirect: "/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
        redirect: "/skins/853c80ef3c3749fdaa49938b674adae6?size=16",
        redirect: "/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2",
        redirect: "/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2&overlay=",
        redirect: "/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2",

Fixed code:
      req.url.searchParams.delete('default');
      req.url.path_list[1] = def;
      req.url.pathname = req.url.path_list.join('/');
      var newUrl = req.url.toString();
  var size = parseInt(req.url.searchParams.get("size")) || config.avatars.default_size;
  var def = req.url.searchParams.get("default");
  var overlay = req.url.searchParams.has("overlay") || req.url.searchParams.has("helm");
  var def = req.url.searchParams.get('default');
      req.url.searchParams.delete('default');
      req.url.path_list[2] = def;
      req.url.pathname = req.url.path_list.join('/');
      var newUrl = req.url.toString();
  var def = req.url.searchParams.get("default");
  var scale = parseInt(req.url.searchParams.get("scale")) || config.renders.default_scale;
  var overlay = req.url.searchParams.has("overlay") || req.url.searchParams.has("helm");
      req.url.searchParams.delete('default');
      req.url.path_list[1] = def;
      req.url.pathname = req.url.path_list.join('/');
      var newUrl = req.url.toString();
  var def = req.url.searchParams.get("default");
  const filename = path.join(__dirname, "public", ...req.url.path_list);
  const relative = path.relative(path.join(__dirname, "public"), filename);
  if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
    fs.access(filename, function(fs_err) {
      if (!fs_err) {
        fs.readFile(filename, function(err, data) {
          callback({
            body: data,
            type: mime.getType(filename),
            err: err,
          });
        });
      } else {
          body: "Not found",
          status: -2,
          code: 404,
      }
    });
  } else {
    callback({
      body: "Forbidden",
      status: -2,
      code: 403,
    });
  }
// splits decoded URL path into an Array
  req.url = new URL(decodeURI(req.url), 'http://' + req.headers.host);
  req.url.pathname = path.resolve('/', req.url.pathname);
        redirect: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
        redirect: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
        redirect: "http://localhost:3000/skins/853c80ef3c3749fdaa49938b674adae6?size=16",
        redirect: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2",
        redirect: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2&overlay=",
        redirect: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2",

    it("should return /public resources", function(done) {
      request.get("http://localhost:3000/javascript/crafatar.js", function(error, res, body) {
        assert.ifError(error);
        assert.strictEqual(res.statusCode, 200);
        done();
      });
    });

    it("should not allow path traversal on /public", function(done) {
      request.get("http://localhost:3000/../server.js", function(error, res, body) {
        assert.ifError(error);
        assert.strictEqual(res.statusCode, 404);
        done();
      });
    });

    it("should not allow encoded path traversal on /public", function(done) {
      request.get("http://localhost:3000/%2E%2E/server.js", function(error, res, body) {
        assert.ifError(error);
        assert.strictEqual(res.statusCode, 404);
        done();
      });
    });