Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions ip-allow-listing-route-service/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# IP Allow-List Route Service

A simple GoLang application that implements the [route service contract](https://docs.cloudfoundry.org/services/route-services.html)
of Cloud Foundry and checks the client IP address against an allow-list before passing on the
request. This requires the `x-cf-true-client-ip` header to be set by the foundation which is the
case on [SAP BTP, Cloud Foundry runtime](https://www.sap.com/products/technology-platform/btp-cloud-foundry-runtime.html).

## Usage

> [!CAUTION]
>
> This is a sample application that is only intended to demonstrate the general idea. It is not
> production grade.

To deploy the application you will first need an application you want to put this in front of, as
well as an allow-list. The allow-list should contain one CIDR per line, empty lines and lines
starting with a `#` are ignored. Example:

```
# This is a comment
10.0.0.0/8
127.0.0.0/8
# The following lines will be ignored


```

Put this file into the app directory next to the manifest file and name it `allowlist.txt`. Now you
can push the application like this (you may need to adjust the domain depending on the region):

```
cf push -f ./manifest.yml --var domain=cfapps.eu10.hana.ondemand.com --var suffix=foo
```

This pushes the app and makes it available at `ip-allow-list-rs-foo.cfapps.eu10.hana.ondemand.com`.
Now we can turn it into a route service and bind it to an already existing route, provide the
domain and host of the route you want restrict access to:

```
cf create-user-provided-service allow-listing -r https://ip-allow-listing-rs-foo.cfapps.eu10.hana.ondemand.com
cf bind-route-service <DOMAIN> --hostname <HOST> allow-listing
```

See the help pages of the individual commands for extended options.

Now every request sent to the route you have bound the route-service to will pass through the
allow-listing route-service and is only forwarded if the client IP adress is allow-listed.
3 changes: 3 additions & 0 deletions ip-allow-listing-route-service/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/sap-samples/cf-routing-samples/ip-allow-listing-route-service

go 1.24.0
174 changes: 174 additions & 0 deletions ip-allow-listing-route-service/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package main

import (
"bufio"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"os"
"strings"
)

var (
ErrBadRequest = fmt.Errorf("bad request")
ErrForbidden = fmt.Errorf("forbidden")
ErrMissingOrInvalidForwardedURL = fmt.Errorf("%w: invalid or missing x-cf-forwarded-url header", ErrBadRequest)
ErrMissingTrueClientIP = fmt.Errorf("%w: missing x-cf-true-client-ip header", ErrBadRequest)
ErrInvalidTrueClientIP = fmt.Errorf("%w: invalid x-cf-true-client-ip header", ErrBadRequest)
)

func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))

err := Main()
if err != nil {
slog.Error("route-service failed", "error", err)
os.Exit(1)
}
}

func Main() error {
allowListFile := os.Getenv("ALLOW_LIST_FILE")
if allowListFile == "" {
return fmt.Errorf("no file to read prefixes from provided")
}

allowedPrefixes, err := loadPrefixes(allowListFile)
if err != nil {
return err
}

s := http.Server{
Addr: ":" + os.Getenv("PORT"),
Handler: &httputil.ReverseProxy{
Director: proxyDirector,
ErrorHandler: proxyErrorHandler,
Transport: &transport{
allowedPrefixes: allowedPrefixes,
roundTripper: &http.Transport{},
},
},
}

slog.Info("starting server", "allow-list", allowedPrefixes, "addr", s.Addr)

return s.ListenAndServe()
}

func loadPrefixes(path string) (prefixes []netip.Prefix, err error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}

s := bufio.NewScanner(f)
for s.Scan() {
l := strings.TrimSpace(s.Text())
if len(l) == 0 || l[0] == '#' {
continue
}

p, err := netip.ParsePrefix(l)
if err != nil {
return nil, err
}

prefixes = append(prefixes, p)
}

if s.Err() != nil {
return nil, s.Err()
}

return prefixes, nil
}

func proxyDirector(req *http.Request) {
log := slog.With("component", "proxy-director", "vcap-id", req.Header.Get("x-vcap-request-id"))

forwardedURL := req.Header.Get("x-cf-forwarded-url")
if forwardedURL == "" {
log.Error("missing x-cf-forwarded-url header")
req.URL = nil
req.Host = ""
return // the transport will deal with that
}

u, err := url.Parse(forwardedURL)
if err != nil {
log.Error("invalid x-cf-forwarded-url header", "error", err)
req.URL = nil
req.Host = ""
return // the transport will deal with that
}

req.URL = u
req.Host = u.Host
}

func proxyErrorHandler(w http.ResponseWriter, r *http.Request, handleErr error) {
log := slog.With("component", "proxy-error-handler", "vcap-id", r.Header.Get("x-vcap-request-id"))

if handleErr == nil {
panic("received nil error")
}

var status int
switch {
case errors.Is(handleErr, ErrBadRequest):
status = http.StatusBadRequest
case errors.Is(handleErr, ErrForbidden):
status = http.StatusForbidden
default:
status = http.StatusBadGateway
}

log.Warn("handling error", "error", handleErr, "status", status)

w.WriteHeader(status)
_, err := fmt.Fprintf(w, "error: %s", handleErr.Error())
if err != nil {
log.Warn("failed to send handleError to client", "handleError", handleErr, "error", err, "status", status)
}
}

type transport struct {
allowedPrefixes []netip.Prefix
roundTripper http.RoundTripper
}

func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL == nil {
return nil, ErrMissingOrInvalidForwardedURL
}

as := req.Header.Get("x-cf-true-client-ip")
if as == "" {
return nil, ErrMissingTrueClientIP
}

a, err := netip.ParseAddr(as)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidTrueClientIP, err)
}

// This is a bit inefficient but for a good enough for a sample. A proper implementation should
// use some form of trie for efficient lookups.
allowed := false
for _, p := range t.allowedPrefixes {
if p.Contains(a) {
allowed = true
break
}
}

if !allowed {
return nil, fmt.Errorf("%w: address '%s' is not in allow-list", ErrForbidden, a.String())
}

return t.roundTripper.RoundTrip(req)
}
10 changes: 10 additions & 0 deletions ip-allow-listing-route-service/manifest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
applications:
- name: ip-allow-listing-route-service
buildpacks:
- go_buildpack
memory: 128M
instances: 1
env:
ALLOW_LIST_FILE: allowlist.txt
routes:
- route: ip-allow-list-rs-((suffix)).((domain))
Loading