Skip to content

Commit

Permalink
Verify JWT (#509)
Browse files Browse the repository at this point in the history
* feat: add jwt nginx modules

* feat: verify jwt and check if the request domain is allowed

* feat: add CORS headers to requests that fail auth.

* ci: add jwt public key to container

* test: add jwt integration tests

* feat: allow no origin header if jwt allow_list has *
  • Loading branch information
guanzo authored Nov 2, 2023
1 parent 62e6d14 commit 0eea635
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 53 deletions.
12 changes: 8 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,17 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

- name: Set orchestrator env (main)
- name: Set Up env (main)
if: github.ref_type == 'tag'
run: echo "NETWORK=main" >> $GITHUB_ENV
run: |
echo "NETWORK=main" >> $GITHUB_ENV
printf ${{ secrets.PRODUCTION_BASE64_JWT_PUBLIC_KEY }} | base64 --decode > ./container/nginx/jwt_pub.key
- name: Set orchestrator env (test)
- name: Set Up env (test)
if: github.ref_type == 'branch'
run: echo "NETWORK=test" >> $GITHUB_ENV
run: |
echo "NETWORK=test" >> $GITHUB_ENV
printf ${{ secrets.STAGING_BASE64_JWT_PUBLIC_KEY }} | base64 --decode > ./container/nginx/jwt_pub.key
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
Expand Down
39 changes: 37 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ ARG NGX_BROTLI_COMMIT=6e975bcb015f62e1f303054897783355e2a877dc
ARG NODEJS_MAJOR_VERSION="18"
# https://github.com/filecoin-project/lassie/releases
ARG LASSIE_VERSION="v0.19.2"
# https://github.com/max-lt/nginx-jwt-module
ARG NGINX_JWT_VERSION="v3.2.2"
ARG LIBJWT_VERSION=1.15.3


#############
# nginx build
Expand All @@ -21,6 +25,8 @@ FROM docker.io/library/debian:bullseye AS build
ARG NGINX_VERSION
ARG NGX_BROTLI_COMMIT
ARG NJS_VERSION
ARG NGINX_JWT_VERSION
ARG LIBJWT_VERSION

# Install dependencies
RUN apt-get update && apt-get install -y --no-install-recommends --no-install-suggests \
Expand All @@ -45,6 +51,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends --no-install-su
clang \
&& rm -rf /var/lib/apt/lists/*


# Install jwt dependencies
RUN apt-get update && apt-get install -y --no-install-recommends --no-install-suggests \
libjansson-dev \
autoconf \
automake \
libtool \
pkg-config \
check \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /usr/src

RUN echo "Cloning brotli $NGX_BROTLI_COMMIT" \
Expand All @@ -64,6 +81,20 @@ RUN echo "Cloning njs $NJS_VERSION" \
&& ./configure \
&& make

RUN echo "Cloning nginx-jwt-module $NGINX_JWT_VERSION" \
&& git clone --depth 1 --branch $NGINX_JWT_VERSION https://github.com/max-lt/nginx-jwt-module.git

RUN echo "Installing libjwt $LIBJWT_VERSION" \
&& mkdir libjwt \
&& curl -sL https://github.com/benmcollins/libjwt/archive/v${LIBJWT_VERSION}.tar.gz \
| tar -zx -C libjwt/ --strip-components=1 \
&& cd libjwt \
&& autoreconf -i \
&& ./configure \
&& make all \
&& make check \
&& make install

ARG CONFIG="--prefix=/etc/nginx \
--sbin-path=/usr/sbin/nginx \
--modules-path=/usr/lib/nginx/modules \
Expand All @@ -86,7 +117,8 @@ ARG CONFIG="--prefix=/etc/nginx \
--with-http_sub_module \
--with-http_v2_module \
--add-dynamic-module=/usr/src/ngx_brotli \
--add-dynamic-module=/usr/src/njs/nginx"
--add-dynamic-module=/usr/src/njs/nginx \
--add-dynamic-module=/usr/src/nginx-jwt-module"

RUN echo "Downloading and extracting nginx $NGINX_VERSION" \
&& mkdir /usr/src/nginx \
Expand All @@ -111,6 +143,9 @@ COPY --from=build /usr/sbin/nginx /usr/sbin/
COPY --from=build /usr/src/nginx/objs/ngx_http_brotli_filter_module.so /usr/lib/nginx/modules/
COPY --from=build /usr/src/nginx/objs/ngx_http_brotli_static_module.so /usr/lib/nginx/modules/
COPY --from=build /usr/src/nginx/objs/ngx_http_js_module.so /usr/lib/nginx/modules/
COPY --from=build /usr/lib/nginx/modules/ngx_http_auth_jwt_module.so /usr/lib/nginx/modules/
COPY --from=build /usr/local/lib/libjwt.so /lib


# Prepare
RUN apt-get update \
Expand All @@ -122,7 +157,7 @@ RUN apt-get update \

# Install dependencies
RUN apt-get update \
&& apt-get install --no-install-recommends -y nodejs speedtest logrotate jq \
&& apt-get install --no-install-recommends -y nodejs speedtest logrotate jq libjansson-dev \
&& rm -rf /var/lib/apt/lists/*

# Download lassie
Expand Down
29 changes: 14 additions & 15 deletions container/nginx/conf.d/shared.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,22 @@ location = / {
return 302 https://saturn.tech;
}

location ~ ^/(ipns|api)/ {
proxy_pass https://ipfs.io;
location / {
js_set $jwt auth.findJWT;
js_content auth.isAllowedRequest;

if ($request_method = 'OPTIONS') {
add_header 'Timing-Allow-Origin' '*';
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
auth_jwt $jwt;
auth_jwt_key /etc/nginx/jwt_pub.key file;
auth_jwt_alg ES256;

location / {
js_content badbits.filterCID;
# These headers are sent if the request fails auth.
add_header 'Saturn-Node-Id' '$node_id' always;
add_header 'Saturn-Transfer-Id' $request_id always;
add_header 'Timing-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Traceparent' always;
add_header 'Access-Control-Expose-Headers' '*' always;
}

location @node_backend {
Expand Down
4 changes: 4 additions & 0 deletions container/nginx/jwt_pub.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiVrZNLTZgPFkyBXI2MDM13e+tmKf
w82SnU183R6CczlsjO4qCTp3Xni+jBUri/5Ng34GZQfljtzZfDMfo2hHRw==
-----END PUBLIC KEY-----
3 changes: 2 additions & 1 deletion container/nginx/nginx.conf
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
load_module /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so;
load_module /usr/lib/nginx/modules/ngx_http_brotli_static_module.so;
load_module /usr/lib/nginx/modules/ngx_http_js_module.so;
load_module /usr/lib/nginx/modules/ngx_http_auth_jwt_module.so;

user nginx;
worker_processes auto;
Expand All @@ -15,7 +16,7 @@ events {
http {
js_path "/etc/nginx/njs/";
js_preload_object denylist.json;
js_import badbits from badbits.js;
js_import auth from auth.js;
js_import ipfsResponse from ipfs-response.js;

include /etc/nginx/mime.types;
Expand Down
78 changes: 78 additions & 0 deletions container/nginx/njs/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import crypto from "crypto";

const ipfsRegex = /^\/ipfs\/(\w+)(\/?.*)/;

function isAllowedRequest(req) {
const matches = req.uri.match(ipfsRegex);
if (!matches) {
return req.internalRedirect("@node_backend");
}
const cid = matches[1];

if (isBadBitsCid(cid)) {
return req.return(410);
}

if (!isAllowedDomain(req)) {
return req.return(403);
}

req.internalRedirect("@node_backend");
}

// TODO implement matching CID paths
// TODO convert CID v0 to CID v1
// implementation ref: https://github.com/protocol/bifrost-infra/blob/af46340bd830728b38a0ea632ca517d04277f78c/ansible/roles/nginx_conf_denylist/files/lua/helpers.lua#L80
function isBadBitsCid(cid) {
// check if root hash(`CID/`) is blocked via denylist.json
const hashedCID = crypto
.createHash("sha256")
.update(cid + "/")
.digest("hex");

/* eslint-disable-next-line no-undef */
return hashedCID in denylist;
}

function isAllowedDomain(req) {
const allowListStr = req.variables.jwt_claim_allow_list;
if (!allowListStr) {
return false;
}

let allowList;
try {
allowList = JSON.parse(allowListStr);
} catch (err) {
return false;
}

if (allowList.includes("*")) {
return true;
}

// Only browser requests are allowed for now.
const requestOrigin = req.variables.http_origin;
if (!requestOrigin) {
return false;
}
const requestDomain = requestOrigin.replace(/^https?:\/\//, "");

const isAllowedDomain = allowList.some((domain) => domain === requestDomain);

return isAllowedDomain;
}

function findJWT(req) {
const jwtQuery = req.variables.arg_jwt;

let jwtHeader = "";
const authHeader = req.variables.http_authorization;
if (authHeader) {
jwtHeader = authHeader.replace("Bearer ", "");
}

return jwtQuery || jwtHeader;
}

export default { isAllowedRequest, findJWT };
29 changes: 0 additions & 29 deletions container/nginx/njs/badbits.js

This file was deleted.

54 changes: 52 additions & 2 deletions scripts/integration_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,30 @@ set -eux

base_url="$1"

# no expire, allow_list: ['*']
jwtAllowAll="eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJjOWM5YTQ4OC1iMzIyLTQ3NjYtOWQyNy1jZDNjY2YwYjEzOGMiLCJzdWIiOiJhYmMxMjMiLCJzdWJUeXBlIjoiY2xpZW50S2V5IiwiYWxsb3dfbGlzdCI6WyIqIl0sImlhdCI6MTY5Nzc2MDcwNH0.U8yFAzv7LvhWX7QSX5Q084ZRJsgd-PySKIfXFyBmzSZdmrJH3FAlpD5BafMPP0NPzdaoZyv5A8-ssGgGA6HlNg"
# no expire, allow_list: ['google.com', 'cnn.com']
jwtAllowExplicit="eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzZjQzNmY1Yi02MjE4LTQ4YjktYWM0MS1jZDUwNzAyMTkxYzgiLCJzdWIiOiJhYmMxMjMiLCJzdWJUeXBlIjoiY2xpZW50S2V5IiwiYWxsb3dfbGlzdCI6WyJnb29nbGUuY29tIiwiY25uLmNvbSJdLCJpYXQiOjE2OTc3NjA3NDd9.qApsm_Bcw80MrzuiGxNM9wUD7gkE_D_AhDI8ILWw4i-Tq3nRyEHauJJdhHM5JBWBjQOHFfSi3VFBv1TR3ww5ig"

test_cid () {
cid="$1"
expected="$2"
code="$(curl -sw "%{http_code}\n" -o /dev/null "${base_url}/ipfs/${cid}")"
code="$(curl -sw "%{http_code}\n" -o /dev/null -H "Origin: https://abc.com" "${base_url}/ipfs/${cid}?jwt=${jwtAllowAll}")"
test "$code" -eq "$expected" || exit 1
}

test_range_request () {
cid="$1"
code="$(curl -sw "%{http_code}\n" -o partial.car -H "Accept: application/vnd.ipld.car" "${base_url}/ipfs/${cid}")"
code="$(curl -sw "%{http_code}\n" -o partial.car -H "Origin: https://abc.com" -H "Authorization: Bearer ${jwtAllowAll}" -H "Accept: application/vnd.ipld.car" "${base_url}/ipfs/${cid}")"
test "$code" -eq 200 || exit 1
ls -lh partial.car
./car ls -v partial.car
}

################
# BAD BITS
################

# we're good this this response code, as going further means a Lassie fetch
not_blocked=501
blocked=410
Expand All @@ -30,6 +39,10 @@ test_cid "bafybeibvcisellj6bfzbas3csvioltujjmif5jqpdw5ykvvwujtvt6up7u" "$blocked
# positive denylist.conf test case
test_cid "bafybeidgnebuxvarpnw2grmkgnamu6cv6" "$blocked"

################
# RANGE REQUESTS
################

# download car tooling
curl -LO -s https://github.com/ipld/go-car/releases/download/v2.8.0/go-car_2.8.0_linux_amd64.tar.gz && tar xzf go-car_2.8.0_linux_amd64.tar.gz

Expand All @@ -42,3 +55,40 @@ test_range_request "QmafUYju2Ab4ETi5HJG1cqjmnjs2xw9PUuBKzU7Hi3zvXU/MC_TheSource.

# range request with offset
test_range_request "bafybeifpz6onienrgwvb3mw5rg7piq5jh63ystjn7s5wk6ttezy2gy5xwu/Mexico.JPG?entity-bytes=1048576:2097152"

################
# JWT Auth
################

authentication_err=401 # jwt missing or invalid
authorization_err=403 # jwt doesn't allow request origin
cid="bafybeifpz6onienrgwvb3mw5rg7piq5jh63ystjn7s5wk6ttezy2gy5xwu/Mexico.JPG"
url="${base_url}/ipfs/${cid}?format=car"

# Requests fail without a jwt
code="$(curl -sw "%{http_code}\n" -o /dev/null "${url}")"
test "$code" -eq "$authentication_err" || exit 1

# Requests fail with explicit allow_list but without an origin header
code="$(curl -sw "%{http_code}\n" -o /dev/null "${url}&jwt=${jwtAllowExplicit}")"
test "$code" -eq "$authorization_err" || exit 1

# Requests fail with explicit allow_list but not allowed origin
code="$(curl -sw "%{http_code}\n" -o /dev/null -H "Origin: https://abc.com" "${url}&jwt=${jwtAllowExplicit}")"
test "$code" -eq "$authorization_err" || exit 1

# Requests succeed with a jwt query param
code="$(curl -sw "%{http_code}\n" -o /dev/null -H "Origin: https://abc.com" "${url}&jwt=${jwtAllowAll}")"
test "$code" -eq 200 || exit 1

# Requests succeed with a jwt auth header
code="$(curl -sw "%{http_code}\n" -o /dev/null -H "Origin: https://abc.com" -H "Authorization: Bearer ${jwtAllowAll}" "${url}")"
test "$code" -eq 200 || exit 1

# Requests succeed with explicit allow_list and allowed origin
code="$(curl -sw "%{http_code}\n" -o /dev/null -H "Origin: https://google.com" "${url}&jwt=${jwtAllowExplicit}")"
test "$code" -eq 200 || exit 1

# Requests succeed with allow_list == [*] and without an origin header
code="$(curl -sw "%{http_code}\n" -o /dev/null "${url}&jwt=${jwtAllowAll}")"
test "$code" -eq 200 || exit 1

0 comments on commit 0eea635

Please sign in to comment.