Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Deploying NGINX Plus as an API Gateway, Part 2: Protecting Backend Services

DZone's Guide to

Deploying NGINX Plus as an API Gateway, Part 2: Protecting Backend Services

This is the second article in our series on deploying NGINX Plus as an API gateway.

· Integration Zone ·
Free Resource

The Future of Enterprise Integration: Learn how organizations are re-architecting their integration strategy with data-driven app integration for true digital transformation.

This is the second article in our series on deploying NGINX Plus as an API gateway:

  • Part 1 provides detailed configuration instructions for several use cases.
  • This post extends those use cases and looks at a range of safeguards that can be applied to protect and secure backend API services in production:
    • Rate Limiting
    • Enforcing Specific Request Methods
    • Applying Fine‑Grained Access Control
    • Controlling Request Sizes
    • Validating Request Bodies

Rate Limiting

Unlike browser‑based clients, individual API clients are able to place huge loads on your APIs, even to the extent of consuming so much of the system resources that other API clients are effectively locked out. Not only malicious clients pose this threat: a misbehaving or buggy API client might enter a loop that overwhelms the backend. To protect against this, we apply a rate limit to ensure fair use by each client and to protect the resources of the backend services.

NGINX Plus can apply rate limits based on any attribute of the request. The client IP address is typically used, but when authentication is enabled for the API, the authenticated client ID is a more reliable and accurate attribute.

Rate limits themselves are defined in the top‑level API gateway configuration file and can then be applied globally, on a per‑API basis, or even per URI.

log_format api_main '$remote_addr - $remote_user [$time_local] "$request"'
                    '$status $body_bytes_sent "$http_referer" "$http_user_agent"'
                    '"$http_x_forwarded_for" "$api_name"';

include api_backends.conf;
include api_keys.conf;

limit_req_zone $binary_remote_addr zone=client_ip_10rs:1m rate=10r/s;
limit_req_zone $http_apikey        zone=apikey_200rs:1m   rate=200r/s;

server {
    set $api_name -; # Start with an undefined API name, each API will update this value
    access_log /var/log/nginx/api_access.log api_main; # Each API may also log to a separate file

    listen 443 ssl;
    server_name api.example.com;

    # TLS config
    ssl_certificate      /etc/ssl/certs/api.example.com.crt;
    ssl_certificate_key  /etc/ssl/private/api.example.com.key;
    ssl_session_cache    shared:SSL:10m;
    ssl_session_timeout  5m;
    ssl_ciphers          HIGH:!aNULL:!MD5;
    ssl_protocols        TLSv1.1 TLSv1.2;

    # API definitions, one per file
    include api_conf.d/*.conf;

    # Error responses
    error_page 404 = @400;         # Invalid paths are treated as bad requests
    proxy_intercept_errors on;     # Do not send backend errors to the client
    include api_json_errors.conf;  # API client friendly JSON error responses
    default_type application/json; # If no content-type then assume JSON

In this example,thelimit_req_zonedirective on line 8 defines a rate limit of 10 requests per second for each client IP address ($binary_remote_addr), and the one on line 9 defines a limit of 200 requests per second for each authenticated client ID ($http_apikey). This illustrates how we can define multiple rate limits independently of where they are applied. An API may apply multiple rate limits at the same time, or apply different rate limits for different resources.

Then, in the following configuration snippet, we use the directive to apply the first rate limit in the policy section of the "Warehouse API" described in Part 1. By default, NGINX Plus sends the response when the rate limit has been exceeded. However, it is helpful for API clients to know explicitly that they have exceeded their rate limit so that they can modify their behavior. To this end, we use thelimit_req_statusdirective to send the response instead.

# Policy section
#
location = /_warehouse {
    internal;
    set $api_name "Warehouse";

    limit_req zone=client_ip_10rs;
    limit_req_status 429;

    proxy_pass http://$upstream$request_uri;
}

You can use additional parameters to the limit_req directive to fine‑tune how NGINX Plus enforces rate limits. For example, it is possible to queue requests instead of rejecting them outright when the limit is exceeded, allowing time for the rate of requests to fall under the defined limit. For more information about fine‑tuning rate limits, see Rate Limiting with NGINX and NGINX Plus on our blog.

Enforcing Specific Request Methods

With RESTful APIs, the HTTP method (or verb) is an important part of each API call and very significant to the API definition. Take the pricing service of our Warehouse API as an example:

GET/api/warehouse/pricing/item001
  • returns the price of item001
PATCH/api/warehouse/pricing/item001
  • changes the price of item001

We can update the definition of the Warehouse API to accept only these two HTTP methods.

With this configuration in place, requests to the pricing service that do not match those listed on line 4 are rejected and are not passed to the backend services. NGINX Plus sends the response to inform the API client of the precise nature of the error, as shown in the following console. Where a minimum‑disclosure security policy is required, the directive can be used to convert this response into a less informative error, for example 400(BadRequest).

$ curl https://api.example.com/api/warehouse/pricing/item001 
{"sku":"item001","price":179.99} 
$ curl -X DELETE https://api.example.com/api/warehouse/pricing/item001 
{"status":405,"message":"Method not allowed"}

Applying Fine-Grained Access Control

Part 1 in this series described how to protect APIs from unauthorized access by enabling authentication options such as API keys and JSON Web Tokens (JWTs). We can use the authenticated ID or attributes of the authenticated ID to perform fine‑grained access control.

Here we show two such examples. The first extends a configuration presented in Part 1 and uses a whitelist of API clients to control access to a specific API resource, based on API key authentication. The second example implements the JWT authentication method mentioned in Part 1, using a custom claim to control which HTTP methods NGINX Plus accepts from the client. Of course, all of the NGINX Plus authentication methods are applicable to these examples.

Controlling Access to Specific Resources

Let's say we want to allow only "infrastructure clients" to access the audit resource of the Warehouse API inventory service. With API key authentication enabled, we use a block to create a whitelist of infrastructure client names so that the variable $is_infrastructure evaluates to 1 when a corresponding API key is used.

map $api_client_name $is_infrastructure {
    default       0;

    "client_one"  1;
    "client_six"  1;
}

In the definition of the Warehouse API, we add a block for the inventory audit resource on lines 13-19. The block ensures that only infrastructure clients can access the resource.

# API definition
#
location /api/warehouse/pricing {
    set $upstream pricing_service;
    rewrite ^ /_warehouse last;
}

location /api/warehouse/inventory {
    set $upstream inventory_service;
    rewrite ^ /_warehouse last;
}

location = /api/warehouse/inventory/audit {
    if ($is_infrastructure = 0) {
        return 403; # Forbidden (not infrastructure)
    }
    set $upstream inventory_service;
    rewrite ^ /_warehouse last;
}

Note that the location directive on line 13 uses the = modifier to make an exact match on the audit resource. Exact matches take precedence over the default path‑prefix definitions used for the other resources. The following trace shows how with this configuration in place a client that isn't on the whitelist is unable to access the inventory audit resource. The API key shown belongs to client_two (as defined in Part 1).

$ curl -H "apikey: QzVV6y1EmQFbbxOfRCwyJs35" 
  https://api.example.com/api/warehouse/inventory/audit 
{"status":403,"message":"Forbidden"}

Controlling Access to Specific Methods

As defined above, the pricing service accepts the GET and PATCH methods, which respectively enable clients to obtain and modify the price of a specific item. (We could also choose to allow the POST and DELETE methods to provide full lifecycle management of pricing data.) In this section, we expand that use case to control which methods specific users can issue. With JWT authentication enabled for the Warehouse API, the permissions for each client are encoded as custom claims. The JWTs issued to administrators who are authorized to make changes to pricing data include the claim "admin":true.

map $request_method $request_type {
    "GET"     "READ";
    "HEAD"    "READ";
    "OPTIONS" "READ";
    default   "WRITE";
}

This block, added to the bottom of api_gateway.conf, coalesces all of the possible HTTP methods into a new variable, $request_type, which evaluates to either READ or WRITE. In the following snippet, we use the $request_type variable to direct requests to the appropriate Warehouse API policy,/_warehouse_READ or /_warehouse_WRITE.

# API definition
#
location /api/warehouse/pricing {
    limit_except GET PATCH DELETE {}
    set $upstream pricing_service;
    rewrite ^ /_warehouse_$request_type last;
}

location /api/warehouse/inventory {
    set $upstream inventory_service;
    rewrite ^ /_warehouse_$request_type last;
}

The directives on lines 6 and 11 append the $request_type variable to the name of the Warehouse API policy, thereby splitting the policy section into two. Now different policies apply to read and write operations.

# Policy section
#
location = /_warehouse_READ {
    internal;
    set $api_name "Warehouse";

    auth_jwt $api_name;
    auth_jwt_key_file /etc/nginx/jwk.json;

    proxy_pass http://$upstream$request_uri;
}

location = /_warehouse_WRITE {
    internal;
    set $api_name "Warehouse";

    auth_jwt $api_name;
    auth_jwt_key_file /etc/nginx/jwk.json;
    if ($jwt_claim_admin != "true") { # Write operations must have "admin":true
        return 403; # Forbidden
    }

    proxy_pass http://$upstream$request_uri;
}

Both the /_warehouse_READ and /_warehouse_WRITE policies require the client to present a valid JWT. However, in the case of a request using a WRITE method( POST, PATCH,orDELETE), we also require that the JWT includes the claim"admin":true(line 32). This approach of having separate policies for different request methods is not limited to authentication. Other controls can also be applied on a per‑method basis, such as rate limiting, logging, and routing to different backends.

JWT authentication is exclusive to NGINX Plus.

Controlling Request Sizes

HTTP APIs commonly use the request body to contain instructions and data for the backend API service to process. This is true of XML/SOAP APIs as well as JSON/REST APIs. Consequently, the request body can pose an attack vector to the backend API services, which may be vulnerable to buffer overflow attacks when processing very large request bodies.

By default, NGINX Plus rejects requests with bodies larger than 1 MB. This can be increased for APIs that specifically deal with large payloads such as image processing, but for mostAPIswe set a lower value.

# Policy section
#
location = /_warehouse {
    internal;
    set $api_name "Warehouse";

    client_max_body_size 16k;

    proxy_pass http://$upstream$request_uri;
}

Theclient_max_body_sizedirective on line 19 limits the size of the request body. With this configuration in place, we can compare the behavior of the API gateway upon receiving two different PATCH requests to the pricing service. The first curl command send a small piece of JSON data, whereas the second command attempts to send the contents of a large file (/etc/services).

$ curl -iX PATCH -d '{"price":199.99}' https://api.example.com/api/warehouse/pricing/item001
HTTP/1.1 204 No Content
Server: nginx/1.13.10
Connection: keep-alive

$ curl -iX PATCH -d@/etc/services https://api.example.com/api/warehouse/pricing/item001
HTTP/1.1 413 Request Entity Too Large
Server: nginx/1.13.10
Content-Type: application/json
Content-Length: 45
Connection: close

{"status":413,"message":"Payload too large"}

Validating Request Bodies

In addition to being vulnerable to buffer overflow attacks with large request bodies, backend API services can be susceptible to bodies that contain invalid or unexpected data. For applications that require correctly formatted JSON in the request body, we can use the NGINX JavaScript module to verify that JSON data is parsed without error before proxying it to the backend API service.

With the JavaScript module installed, we use the directive to reference the file containing the JavaScript code for the function that validates JSON data.

js_include json_validator.js;
js_set $validated json_validator;

The directive defines a new variable, $validated, which is evaluated by calling the json_validator function.

function json_validator(req) {
    try {
        if ( req.variables.request_body.length > 0 ) {
            JSON.parse(req.variables.request_body);
        }
        return req.variables.upstream;
    } catch (e) {
        req.log('JSON.parse exception');
        return '127.0.0.1:10415'; // Address for error response
    }
}

The json_validator function attempts to parse the request body using the JSON.parse method. If successful, then the name of the intended upstream group for this request is returned (line 6). If the request body cannot be parsed (causing an exception), then a local server address is returned (line 9). The directive populates the $validated variable so that we can use it to determine where to send the request.

# Policy section
#
location = /_warehouse {
    internal;
    set $api_name "Warehouse";

    mirror /_NULL;                    # Create a copy of the request to capture request body
    client_body_in_single_buffer on;  # Minimize memory copy operations on request body
    client_body_buffer_size      16k; # Largest body to keep in memory (before writing to file)
    client_max_body_size         16k;

    proxy_pass http://$validated$request_uri;
}

In the policy section for the Warehouse API, we modify the directive on line 23. It passes the request to the backend API service as before but now uses the $validated variable as the destination address. If the client body was successfully parsed as JSON then we proxy to the upstream group as normal. If however, there was an exception, we use the returned value of 127.0.0.1:10415 to send an error response to the client.

server {
    listen 127.0.0.1:10415; # This is the error response of json_validator()
    return 415; # Unsupported media type
    include api_json_errors.conf;
}

When requests are proxied to this virtual server, NGINX Plus sends the response to the client.

With this complete configuration in place, NGINX Plus proxies requests to the backend API service only if they have correctly formatted JSON bodies.

$ curl -iX POST -d '{"sku":"item002","price":85.00}' https://api.example.com/api/warehouse/pricing
HTTP/1.1 201 Created
Server: nginx/1.13.10
Location: /api/warehouse/pricing/item002

$ curl -X POST -d 'item002=85.00' https://api.example.com/api/warehouse/pricing
{"status":415,"message":"Unsupported media type"}

A Note about the $request_body Variable

The JavaScript function json_validator uses the$request_bodyvariable to perform JSON parsing. However, NGINX Plus does not populate this variable by default, and simply streams the request body to the backend without making intermediate copies. By using the directive inside the Warehouse API policy section (line 19) we create a copy of the client request, and consequently, populate the $request_body variable.

# Policy section
#
location = /_warehouse {
    internal;
    set $api_name "Warehouse";

    mirror /_NULL;                    # Create a copy of the request to capture request body
    client_body_in_single_buffer on;  # Minimize memory copy operations on request body
    client_body_buffer_size      16k; # Largest body to keep in memory (before writing to file)
    client_max_body_size         16k;

    proxy_pass http://$validated$request_uri;
}

The directives on lines 20 and 21 control how NGINX Plus handles the request body internally. We setclient_body_buffer_sizeto the same size asclient_max_body_sizeso that the request body is not written to disk. This improves overall performance by minimizing disk I/O operations, but at the expense of additional memory utilization. For most API gateway use cases with small request bodies this is a good compromise.

As mentioned above, the mirror directive creates a copy of the client request. Other than populating $request_body,we have no need for this copy so we send it to a "dead end" location (/_NULL) that we define in the top‑level API gateway entry point.

    # Dummy location used to populate $request_body for JSON validation
    location = /_NULL {
        internal;
        return 204;
    }

This location does nothing more than send the response. As this response is related to a mirrored request it is ignored, therefore adding negligible overhead to the processing of the original client request.

Summary

In this second post of our series about deploying NGINX Plus as an API gateway, we focused on the challenge of protecting backend API services in a production environment from malicious and misbehaving clients. NGINX Plus uses the same technology for managing API traffic that is used to power and protect the busiest sites on the Internet today.

Check out the other post in this series:

  • Part 1 explains how to configure NGINX Plus in some essential API gateway use cases.

To try NGINX Plus as an API gateway, and use the complete set of configuration files from our GitHub Gist repo.

Make your mark on the industry’s leading annual report. Fill out the State of API Integration 2019 Survey and receive $25 to the Cloud Elements store.

Topics:
integration

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}