-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmain.go
346 lines (291 loc) · 8.65 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
package proxy
import (
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/golang-jwt/jwt/v4"
"github.com/kelseyhightower/envconfig"
"go.uber.org/zap"
)
const (
CaddyVarUpstream = "upstream"
CaddyVarRedirectURL = "redirect_url"
)
// CookieFlag is a query string flag to indicate a cookie has been requested. It remains set until the requested
// cookie has been verified. This is required to support user agents that do not allow cookies.
const CookieFlag = "cf"
// Interface guards
var (
_ caddy.Provisioner = (*Proxy)(nil)
_ caddyhttp.MiddlewareHandler = (*Proxy)(nil)
)
func init() {
caddy.RegisterModule(Proxy{})
httpcaddyfile.RegisterHandlerDirective("dynamic_proxy", newDynamicProxy)
}
type ProxyClaim struct {
Level string `json:"level"`
IsValid bool
jwt.RegisteredClaims
}
type Proxy struct {
DefaultSite string `required:"true" split_words:"true"`
Host string `required:"true"`
TokenSecret string `required:"true" split_words:"true"`
Sites AuthSites `required:"true" split_words:"true"`
ManagementAPI string `required:"true" split_words:"true"`
// optional params
CookieName string `split_words:"true" default:"_auth_proxy"`
ReturnToParam string `split_words:"true" default:"returnTo"`
RobotsTxtDisable bool `split_words:"true" default:"false"`
TokenParam string `split_words:"true" default:"token"`
TokenPath string `split_words:"true" default:"/auth/token"`
TrustedBots []string `split_words:"true" default:"googlebot"`
// Secret is the binary token secret. Must be exported to be valid after being passed back from Caddy.
Secret []byte `ignored:"true"`
log *zap.Logger `ignored:"true"`
}
type Error struct {
// Message contains a message that is safe for display to the end user
Message string
// Status is the http status code for the response
Status int
// err contains the original error message, not necessarily safe for display to the end user
err error
}
func (e *Error) Error() string {
return e.err.Error()
}
func (Proxy) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.handlers.dynamic_proxy",
New: func() caddy.Module { return new(Proxy) },
}
}
func (p *Proxy) Provision(ctx caddy.Context) error {
p.log = ctx.Logger(p)
return nil
}
func (p Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
if !p.RobotsTxtDisable {
// setup the robots.txt options
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
if r.URL.Path == "/robots.txt" {
_, err := w.Write([]byte("User-agent: * Disallow: /"))
if err != nil {
return fmt.Errorf("failed to write robots.txt: %w", err)
}
return nil
}
}
if r.URL.Path == "/status" {
w.WriteHeader(http.StatusNoContent)
return nil
}
if err := p.handleRequest(w, r); err != nil {
var proxyError *Error
if errors.As(err, &proxyError) {
w.WriteHeader(proxyError.Status)
_, writeErr := w.Write([]byte(proxyError.Message))
if writeErr != nil {
p.log.Error("couldn't write to response buffer: %s" + writeErr.Error())
}
p.log.Error(proxyError.Message, zap.Int("status", proxyError.Status), zap.String("error", err.Error()))
}
return err
}
return next.ServeHTTP(w, r)
}
func (p Proxy) handleRequest(w http.ResponseWriter, r *http.Request) error {
if p.isTrustedBot(r) {
upstream := p.DefaultSite
p.setVar(r, CaddyVarUpstream, upstream)
p.log.Info("trusted bot", zap.String("user-agent", r.UserAgent()), zap.String("upstream", upstream))
return nil
}
queryToken := p.getTokenFromQueryString(r)
queryClaim := p.getClaimFromToken(queryToken)
cookieToken := p.getTokenFromCookie(r)
cookieClaim := p.getClaimFromToken(cookieToken)
var token string
var claim ProxyClaim
if queryClaim.IsValid {
token = queryToken
claim = queryClaim
} else if cookieClaim.IsValid {
token = cookieToken
claim = cookieClaim
} else {
p.log.Info("no valid token found, calling management API", zap.String("URL", p.ManagementAPI+p.TokenPath))
return p.getNewToken(w, r)
}
// if a cookie hasn't been requested, try to set one
flag := p.getFlag(r)
if !flag {
// set a cookie if we don't have a valid one OR if we need to replace it with a new one
if !cookieClaim.IsValid || claimsAreValidAndDifferent(queryClaim, cookieClaim) {
p.setCookie(w, token, claim.ExpiresAt.Time)
p.setFlag(r)
return nil
}
}
// if the cookie is valid, it's safe to clear the query string
if cookieClaim.IsValid {
redirect := false
if queryToken != "" {
p.clearQueryToken(r)
redirect = true
}
if flag {
p.clearFlag(r)
redirect = true
}
if redirect {
return nil
}
}
returnTo := r.URL.Query().Get(p.ReturnToParam)
if returnTo != "" && p.isTrusted(returnTo) {
p.setVar(r, CaddyVarRedirectURL, returnTo)
return nil
}
upstream := p.getSite(claim.Level)
p.setVar(r, CaddyVarUpstream, upstream)
return nil
}
func (p *Proxy) isTrusted(returnTo string) bool {
if strings.HasPrefix(returnTo, p.ManagementAPI) {
return true
}
if strings.HasPrefix(returnTo, p.Host) {
return true
}
return false
}
func (p Proxy) setVar(r *http.Request, name, value string) {
caddyhttp.SetVar(r.Context(), name, value)
p.log.Debug("setting " + name + " to " + value)
}
func newDynamicProxy(_ httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
return newProxy()
}
func newProxy() (Proxy, error) {
var p Proxy
if err := envconfig.Process("", &p); err != nil {
return p, err
}
var err error
p.Secret, err = base64.StdEncoding.DecodeString(p.TokenSecret)
if err != nil {
return p, fmt.Errorf("unable to decode Proxy TokenSecret: %w", err)
}
for i := range p.TrustedBots {
p.TrustedBots[i] = strings.ToLower(p.TrustedBots[i])
}
return p, nil
}
func (p Proxy) getTokenFromQueryString(r *http.Request) string {
return r.URL.Query().Get(p.TokenParam)
}
func (p Proxy) getTokenFromCookie(r *http.Request) string {
cookie, err := r.Cookie(p.CookieName)
if err != nil {
return ""
}
return cookie.Value
}
func (p Proxy) getSite(level string) string {
upstream, ok := p.Sites[level]
if !ok {
return p.DefaultSite
}
return upstream
}
func (p Proxy) clearQueryToken(r *http.Request) {
u := r.URL
q := u.Query()
q.Del(p.TokenParam)
u.RawQuery = q.Encode()
p.setVar(r, CaddyVarRedirectURL, u.String())
}
func (p Proxy) setCookie(w http.ResponseWriter, token string, expiry time.Time) {
ck := http.Cookie{
Name: p.CookieName,
Value: token,
Expires: expiry,
Path: "/",
}
http.SetCookie(w, &ck)
}
func (p Proxy) getClaimFromToken(token string) ProxyClaim {
if token == "" {
return ProxyClaim{}
}
var claim ProxyClaim
_, err := jwt.ParseWithClaims(token, &claim, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
err := &Error{
err: fmt.Errorf("unexpected signing method: %v", token.Header["alg"]),
Message: "error: invalid access token",
Status: http.StatusBadRequest,
}
return nil, err
}
return p.Secret, nil
})
if errors.Is(err, jwt.ErrTokenExpired) {
p.log.Error("jwt token has expired: " + err.Error())
} else if err != nil {
p.log.Error("failed to parse token", zap.Error(err), zap.String("token", token))
} else {
claim.IsValid = true
}
return claim
}
func (p Proxy) setFlag(r *http.Request) {
u := r.URL
q := u.Query()
q.Add(CookieFlag, "1")
u.RawQuery = q.Encode()
p.setVar(r, CaddyVarRedirectURL, u.String())
}
func (p Proxy) clearFlag(r *http.Request) {
u := r.URL
q := u.Query()
q.Del(CookieFlag)
u.RawQuery = q.Encode()
p.setVar(r, CaddyVarRedirectURL, u.String())
}
func (p Proxy) getFlag(r *http.Request) bool {
return r.URL.Query().Get(CookieFlag) != ""
}
// getNewToken uses a redirect to get a new token from the management API
func (p Proxy) getNewToken(_ http.ResponseWriter, r *http.Request) error {
p.log.Info("redirecting to management API")
p.setVar(r, CaddyVarRedirectURL, p.ManagementAPI+p.TokenPath+"?returnTo="+url.QueryEscape(p.Host+r.URL.Path))
return nil
}
// isTrustedBot compares the user agent in the request against a list of trusted bots in the configuration and
// returns true if the user agent contains one of the configured keywords.
func (p Proxy) isTrustedBot(r *http.Request) bool {
userAgent := strings.ToLower(r.UserAgent())
if userAgent == "" {
return false
}
for _, s := range p.TrustedBots {
if strings.Contains(userAgent, s) {
return true
}
}
return false
}
func claimsAreValidAndDifferent(a, b ProxyClaim) bool {
return a.IsValid && b.IsValid && !a.IssuedAt.Time.Equal(b.IssuedAt.Time)
}