Skip to content

Commit 0d1ef10

Browse files
committed
feat: add ip allow-list route-service
1 parent c881b0a commit 0d1ef10

File tree

4 files changed

+234
-0
lines changed

4 files changed

+234
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# IP Allow-List Route Service
2+
3+
A simple GoLang application that implements the [route service contract](https://docs.cloudfoundry.org/services/route-services.html)
4+
of Cloud Foundry and checks the client IP address against an allow-list before passing on the
5+
request. This requires the `x-cf-true-client-ip` header to be set by the foundation which is the
6+
case on [SAP BTP, Cloud Foundry runtime](https://www.sap.com/products/technology-platform/btp-cloud-foundry-runtime.html).
7+
8+
## Usage
9+
10+
> [!CAUTION]
11+
>
12+
> This is a sample application that is only intended to demonstrate the general idea. It is not
13+
> production grade.
14+
15+
To deploy the application you will first need an application you want to put this in front of, as
16+
well as an allow-list. The allow-list should contain one CIDR per line, empty lines and lines
17+
starting with a `#` are ignored. Example:
18+
19+
```
20+
# This is a comment
21+
10.0.0.0/8
22+
127.0.0.0/8
23+
# The following lines will be ignored
24+
25+
26+
```
27+
28+
Put this file into the app directory next to the manifest file and name it `allowlist.txt`. Now you
29+
can push the application like this (you may need to adjust the domain depending on the region):
30+
31+
```
32+
cf push -f ./manifest.yml --var domain=cfapps.eu10.hana.ondemand.com --var suffix=foo
33+
```
34+
35+
This pushes the app and makes it available at `ip-allow-list-rs-foo.cfapps.eu10.hana.ondemand.com`.
36+
Now we can turn it into a route service and bind it to an already existing route, provide the
37+
domain and host of the route you want restrict access to:
38+
39+
```
40+
cf create-user-provided-service allow-listing -r https://ip-allow-listing-rs-foo.cfapps.eu10.hana.ondemand.com
41+
cf bind-route-service <DOMAIN> --hostname <HOST> allow-listing
42+
```
43+
44+
See the help pages of the individual commands for extended options.
45+
46+
Now every request sent to the route you have bound the route-service to will pass through the
47+
allow-listing route-service and is only forwarded if the client IP adress is allow-listed.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/sap-samples/cf-routing-samples/ip-allow-listing-route-service
2+
3+
go 1.24.0
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"net/http"
9+
"net/http/httputil"
10+
"net/netip"
11+
"net/url"
12+
"os"
13+
"strings"
14+
)
15+
16+
var (
17+
ErrBadRequest = fmt.Errorf("bad request")
18+
ErrForbidden = fmt.Errorf("forbidden")
19+
ErrMissingOrInvalidForwardedURL = fmt.Errorf("%w: invalid or missing x-cf-forwarded-url header", ErrBadRequest)
20+
ErrMissingTrueClientIP = fmt.Errorf("%w: missing x-cf-true-client-ip header", ErrBadRequest)
21+
ErrInvalidTrueClientIP = fmt.Errorf("%w: invalid x-cf-true-client-ip header", ErrBadRequest)
22+
)
23+
24+
func main() {
25+
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))
26+
27+
err := Main()
28+
if err != nil {
29+
slog.Error("route-service failed", "error", err)
30+
os.Exit(1)
31+
}
32+
}
33+
34+
func Main() error {
35+
allowListFile := os.Getenv("ALLOW_LIST_FILE")
36+
if allowListFile == "" {
37+
return fmt.Errorf("no file to read prefixes from provided")
38+
}
39+
40+
allowedPrefixes, err := loadPrefixes(allowListFile)
41+
if err != nil {
42+
return err
43+
}
44+
45+
s := http.Server{
46+
Addr: ":" + os.Getenv("PORT"),
47+
Handler: &httputil.ReverseProxy{
48+
Director: proxyDirector,
49+
ErrorHandler: proxyErrorHandler,
50+
Transport: &transport{
51+
allowedPrefixes: allowedPrefixes,
52+
roundTripper: &http.Transport{},
53+
},
54+
},
55+
}
56+
57+
slog.Info("starting server", "allow-list", allowedPrefixes, "addr", s.Addr)
58+
59+
return s.ListenAndServe()
60+
}
61+
62+
func loadPrefixes(path string) (prefixes []netip.Prefix, err error) {
63+
f, err := os.Open(path)
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
s := bufio.NewScanner(f)
69+
for s.Scan() {
70+
l := strings.TrimSpace(s.Text())
71+
if len(l) == 0 || l[0] == '#' {
72+
continue
73+
}
74+
75+
p, err := netip.ParsePrefix(l)
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
prefixes = append(prefixes, p)
81+
}
82+
83+
if s.Err() != nil {
84+
return nil, s.Err()
85+
}
86+
87+
return prefixes, nil
88+
}
89+
90+
func proxyDirector(req *http.Request) {
91+
log := slog.With("component", "proxy-director", "vcap-id", req.Header.Get("x-vcap-request-id"))
92+
93+
forwardedURL := req.Header.Get("x-cf-forwarded-url")
94+
if forwardedURL == "" {
95+
log.Error("missing x-cf-forwarded-url header")
96+
req.URL = nil
97+
req.Host = ""
98+
return // the transport will deal with that
99+
}
100+
101+
u, err := url.Parse(forwardedURL)
102+
if err != nil {
103+
log.Error("invalid x-cf-forwarded-url header", "error", err)
104+
req.URL = nil
105+
req.Host = ""
106+
return // the transport will deal with that
107+
}
108+
109+
req.URL = u
110+
req.Host = u.Host
111+
}
112+
113+
func proxyErrorHandler(w http.ResponseWriter, r *http.Request, handleErr error) {
114+
log := slog.With("component", "proxy-error-handler", "vcap-id", r.Header.Get("x-vcap-request-id"))
115+
116+
if handleErr == nil {
117+
panic("received nil error")
118+
}
119+
120+
var status int
121+
switch {
122+
case errors.Is(handleErr, ErrBadRequest):
123+
status = http.StatusBadRequest
124+
case errors.Is(handleErr, ErrForbidden):
125+
status = http.StatusForbidden
126+
default:
127+
status = http.StatusBadGateway
128+
}
129+
130+
log.Warn("handling error", "error", handleErr, "status", status)
131+
132+
w.WriteHeader(status)
133+
_, err := fmt.Fprintf(w, "error: %s", handleErr.Error())
134+
if err != nil {
135+
log.Warn("failed to send handleError to client", "handleError", handleErr, "error", err, "status", status)
136+
}
137+
}
138+
139+
type transport struct {
140+
allowedPrefixes []netip.Prefix
141+
roundTripper http.RoundTripper
142+
}
143+
144+
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
145+
if req.URL == nil {
146+
return nil, ErrMissingOrInvalidForwardedURL
147+
}
148+
149+
as := req.Header.Get("x-cf-true-client-ip")
150+
if as == "" {
151+
return nil, ErrMissingTrueClientIP
152+
}
153+
154+
a, err := netip.ParseAddr(as)
155+
if err != nil {
156+
return nil, fmt.Errorf("%w: %w", ErrInvalidTrueClientIP, err)
157+
}
158+
159+
// This is a bit inefficient but for a good enough for a sample. A proper implementation should
160+
// use some form of trie for efficient lookups.
161+
allowed := false
162+
for _, p := range t.allowedPrefixes {
163+
if p.Contains(a) {
164+
allowed = true
165+
break
166+
}
167+
}
168+
169+
if !allowed {
170+
return nil, fmt.Errorf("%w: address '%s' is not in allow-list", ErrForbidden, a.String())
171+
}
172+
173+
return t.roundTripper.RoundTrip(req)
174+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
applications:
2+
- name: ip-allow-listing-route-service
3+
buildpacks:
4+
- go_buildpack
5+
memory: 128M
6+
instances: 1
7+
env:
8+
ALLOW_LIST_FILE: allowlist.txt
9+
routes:
10+
- route: ip-allow-list-rs-((suffix)).((domain))

0 commit comments

Comments
 (0)