Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
48 changes: 48 additions & 0 deletions ip-allow-listing-route-service/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 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 this route-service you will first need a separate application you want to put this in
front of, as well as an allow-list. The allow-list should contain one IP-prefix 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.example.com --var suffix=foo
```

This pushes the app and makes it available at `ip-allow-list-rs-foo.cfapps.example.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.example.com
cf bind-route-service cfapps.example.com --hostname my-app 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 to the target application if the client IP
address 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
179 changes: 179 additions & 0 deletions ip-allow-listing-route-service/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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"))

log.Info("handling request", "client-ip", req.RemoteAddr)

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
}

log.Debug("obtained forwarded URL", "forwarded-url", u.String())

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) {
// This indicates that an issue was encountered while trying to read the forwarded URL.
if req.URL == nil {
return nil, ErrMissingOrInvalidForwardedURL
}

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

addr, err := netip.ParseAddr(addrString)
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(addr) {
allowed = true
break
}
}

if !allowed {
return nil, fmt.Errorf("%w: address '%s' is not in allow-list", ErrForbidden, addr.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