Hiding my nodejs application code within a docker container

Hiding my nodejs application code within a docker container

ยท

10 min read

Building binaries on compiled languages like Rust and Go as Static or dynamic linking makes a huge step on code security, I mean no one can access your code, reading it !, but in many use cases they can by doing some complicated reverse engineering methods ... actually it's hard! it becomes harder when you built your app as a static linking and bundle all the shit ๐Ÿ’ฉ together. Nodejs applications can be read it easily ... all your source code is accessible, In this post, we will hide our node app and "COMPILE" it! seems awesome right! let's go folks ๐Ÿ˜‡ !

Get the app

You need to have nodejs installed to test the app locally, I tested the app on node 12.2.0. You might have some problems with the database so make sure to use the appropriate mongoose version that is compatible with MongoDB, I have mongoose 5.10.10 that is compatible with mongo 4.4. Let's run the app ๐Ÿค“ !

Clone the code

Make sure you have Git installed!

git clone https://github.com/hatembentayeb/node-rest-api-mongo.git 
cd node-rest-api-mongo
npm install

Run a mongo instance

make sure you have docker installed, otherwise install mongo directly.

docker run -p 27017:27017 --name mymongo -d mongo:latest

Modify the .env file

On the mongo uri we have the ip of the mongo container, you can use localhost:27017 instead, but we need the IP when we run the app inside the container.

PORT=3000
MONGODB_URI=mongodb://172.17.0.3:27017
DB_NAME=hatem

Run the app

You can run it with node app.js

npm start

Insert data

Using curl with POST request to send data inside the products.json. the jq command is just for formatting JSON.

curl  -X POST -H "Content-Type: application/json" "http://localhost:3000/products" -d @products.json | jq

Get data

Simple GET request to get all requested data

curl http://localhost:3000/products | jq

Why we need a binary

This approach! i mean building binary can save us especially to get rid of the node_modules inside our container! we will save space and enhance the image size! besides we don't need any base node image like FROM node: latest because it's just a binary that needs a shell to run, also you have to make a choice of your build platform or architecture, you can find the full list of the available architectures for nexe on this link full list.

In this tutorial, we need a minimal docker image ... like 5MB of size ! , that's awesome ! yes, it's is the alpine image, the smallest one! now we can deploy a docker image with a low size! it will be extremely fast believe me, if you have a small server with limited os storage or an on-premise docker registry with limited storage it will be wonderful to store images with small size !!

If you try to build the project with nexe and then use it on a docker container based on alpine, it will work !, only in your dreams ๐Ÿคฃ. To be able to run it on alpine we need to set the right architecture platform before the build.

In terms of security, you are almost secure they can't get to your source code! it's a binary! dude, but they can access your container if they succeeded to create shell access with the RCE (remote code execution) attack ... they can do it by attacking your application and exploit it, so they are on your container now! they can't read your code but they can crash your container and access your host and here is the disaster ๐Ÿ˜ณ ! so I will try to limit the privileges on the container by disabling the root access and create a non-root user and remove some of the default Linux commands like ls, cat and mv ๐Ÿ˜ˆ.

Anyways, let's start building binaries ๐Ÿ˜‹

Let's build it

In order to build the binary, we need a special package called nexe with over then 9k stars on GitHub ๐Ÿง !!

To install it run this command npm install -g nexe and check the package version nexe -v, I have the 4.0.0-beta.16 version.

To build the application just run this command! :

next app.js -t alpine-x86-12.2.0 -o mybinary

let's breakdown the nexe options :

  • app.js: is your application entry point
  • alpine-x86-12.2.0: is the node version that is compatible with the alpine architecture platform with is x86
  • mybinary: is your binary output file that we will copy to the container.

In some cases you need to bundle some HTML files to your binary or any some directories, you can do that by adding the --recursive/-r option and specify the path like this :

nexe app.js -t alpine-x86-12.2.0 -r static/**/*.html -o mybinary

Alright let's run the binary :

$ ./mybinary 

Server started on port 3000...
Mongoose connected to db...
Mongodb connected....

And here we go! it's much like a command! try to move it to another location on your file system and run it again! awesome it works smoothly, congrats! you have a standalone binary for your node app ๐Ÿ˜ !

Move it to docker

let's run the app with docker, we need a dockerfile to build the image, here is an example:

FROM alpine
WORKDIR app
RUN adduser --disabled-password btx
RUN rm -f /bin/cat /bin/ls /bin/mv /bin/find /bin/cd 
COPY hatem .
COPY .env .
EXPOSE 3000
RUN chown -R btx:btx /app
USER btx
CMD  ["./mybinary"]

We will base the container on a lightweight docker image: Alpine it's about 5MB in size! then we define our working directory /app to be the default directory for the rest of all commands and when you access the container via exec. Adding a non-root user called btx without a password and remove some of the basic Linux commands like ls , cat , mv , find and cd. Now no need to copy the whole project ๐Ÿฅฒ, just that tiny binary! and that's it. We know that the app depends on a .env file so we copy it inside the container.

Exposing the container port is dependent on the port number on the .env file. Now all artifacts are in the right place, so now let's change the directory ownership to the btx user and activate that user to be the default one when we access the container via exec. The final command is to start the app as a single process on the container.

Time to cook ๐Ÿ˜Š

Build the docker image :

$ docker build -t node-api-crud:v1 . --no-cache

Run the docker image :

$ docker run -p 3000:3000 --name node-rest  node-api-crud:v1
Server started on port 3000...
Mongoose connected to db...
Mongodb connected....

We can now test the app and start inserting data and getting them via curl via localhost:3000

Final thoughts

To ensure more security test try to use :

  • npm audit fix to fix any dependency vulnerability.
  • Anshore for scanning the docker image and check if there is any critical CVE on the system packages.
  • Trusted images from official docker hub repos, or make your own.
  • Sonarqube to make some SAST and discover potential security issues on your code.

ย ย ย ย ย ย ย ย ย You build it, you run it, you secure it