From 0a23c2fc0e63609b6efcad1256c9582c96f66a79 Mon Sep 17 00:00:00 2001 From: Anders Pitman Date: Wed, 16 Feb 2022 11:44:24 -0700 Subject: [PATCH 01/10] Limit token permissions Added the ability to scope tokens to a specific client. If enabled, this has the affect of limiting the token to being used to list tunnels for that specific client. It can't be used for the web UI or for any state-changing actions such as creating new tunnels. --- api.go | 63 +++++++++++++++++++++++++++++---- boringproxy.go | 2 +- database.go | 10 ++++-- templates/add_token_client.tmpl | 14 ++++++++ templates/tokens.tmpl | 2 +- ui_handler.go | 27 ++++++++++++++ 6 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 templates/add_token_client.tmpl diff --git a/api.go b/api.go index 030d722..b75211e 100644 --- a/api.go +++ b/api.go @@ -59,8 +59,24 @@ func (a *Api) handleTunnels(w http.ResponseWriter, r *http.Request) { tunnels := a.GetTunnels(tokenData) - if len(query["client-name"]) == 1 { - clientName := query["client-name"][0] + // If the token is limited to a specific client, filter out + // tunnels for any other clients. + if tokenData.Client != "" { + for k, tun := range tunnels { + if tokenData.Client != tun.ClientName { + delete(tunnels, k) + } + } + } + + clientName := query.Get("client-name") + if clientName != "" && tokenData.Client != "" && clientName != tokenData.Client { + w.WriteHeader(403) + w.Write([]byte("Token is not valid for this client")) + return + } + + if clientName != "" { for k, tun := range tunnels { if tun.ClientName != clientName { delete(tunnels, k) @@ -85,6 +101,13 @@ func (a *Api) handleTunnels(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) case "POST": + + if tokenData.Client != "" { + w.WriteHeader(403) + io.WriteString(w, fmt.Sprintf("Token can only be used to list tunnels for client %s", tokenData.Client)) + return + } + r.ParseForm() _, err := a.CreateTunnel(tokenData, r.Form) if err != nil { @@ -92,6 +115,12 @@ func (a *Api) handleTunnels(w http.ResponseWriter, r *http.Request) { w.Write([]byte(err.Error())) } case "DELETE": + if tokenData.Client != "" { + w.WriteHeader(403) + io.WriteString(w, fmt.Sprintf("Token can only be used to list tunnels for client %s", tokenData.Client)) + return + } + r.ParseForm() err := a.DeleteTunnel(tokenData, r.Form) if err != nil { @@ -119,6 +148,12 @@ func (a *Api) handleUsers(w http.ResponseWriter, r *http.Request) { return } + if tokenData.Client != "" { + w.WriteHeader(403) + io.WriteString(w, fmt.Sprintf("Token can only be used to list tunnels for client %s", tokenData.Client)) + return + } + path := r.URL.Path parts := strings.Split(path[1:], "/") @@ -178,6 +213,12 @@ func (a *Api) handleTokens(w http.ResponseWriter, r *http.Request) { return } + if tokenData.Client != "" { + w.WriteHeader(403) + io.WriteString(w, fmt.Sprintf("Token can only be used to list tunnels for client %s", tokenData.Client)) + return + } + switch r.Method { case "POST": r.ParseForm() @@ -326,6 +367,7 @@ func (a *Api) CreateTunnel(tokenData TokenData, params url.Values) (*Tunnel, err } func (a *Api) DeleteTunnel(tokenData TokenData, params url.Values) error { + domain := params.Get("domain") if domain == "" { return errors.New("Invalid domain parameter") @@ -355,14 +397,21 @@ func (a *Api) CreateToken(tokenData TokenData, params url.Values) (string, error return "", errors.New("Invalid owner paramater") } - if tokenData.Owner != owner { - user, _ := a.db.GetUser(tokenData.Owner) - if !user.IsAdmin { - return "", errors.New("Unauthorized") + user, _ := a.db.GetUser(tokenData.Owner) + + if tokenData.Owner != owner && !user.IsAdmin { + return "", errors.New("Unauthorized") + } + + client := params.Get("client") + + if client != "any" { + if _, exists := user.Clients[client]; !exists { + return "", errors.New(fmt.Sprintf("Client %s does not exist for user %s", client, owner)) } } - token, err := a.db.AddToken(owner) + token, err := a.db.AddToken(owner, client) if err != nil { return "", errors.New("Failed to create token") } diff --git a/boringproxy.go b/boringproxy.go index b8e4761..6d74ded 100644 --- a/boringproxy.go +++ b/boringproxy.go @@ -128,7 +128,7 @@ func Listen() { users := db.GetUsers() if len(users) == 0 { db.AddUser("admin", true) - _, err := db.AddToken("admin") + _, err := db.AddToken("admin", "any") if err != nil { log.Fatal("Failed to initialize admin user") } diff --git a/database.go b/database.go index 0416a00..761dbc5 100644 --- a/database.go +++ b/database.go @@ -20,7 +20,8 @@ type Database struct { } type TokenData struct { - Owner string `json:"owner"` + Owner string `json:"owner"` + Client string `json:"client,omitempty"` } type User struct { @@ -142,7 +143,7 @@ func (d *Database) DeleteDNSRequest(requestId string) { delete(d.dnsRequests, requestId) } -func (d *Database) AddToken(owner string) (string, error) { +func (d *Database) AddToken(owner, client string) (string, error) { d.mutex.Lock() defer d.mutex.Unlock() @@ -156,7 +157,10 @@ func (d *Database) AddToken(owner string) (string, error) { return "", errors.New("Could not generat token") } - d.Tokens[token] = TokenData{owner} + d.Tokens[token] = TokenData{ + Owner: owner, + Client: client, + } d.persist() diff --git a/templates/add_token_client.tmpl b/templates/add_token_client.tmpl new file mode 100644 index 0000000..b96acef --- /dev/null +++ b/templates/add_token_client.tmpl @@ -0,0 +1,14 @@ +{{ template "header.tmpl" . }} +

Add Token

+
+ + + + +
+{{ template "footer.tmpl" . }} diff --git a/templates/tokens.tmpl b/templates/tokens.tmpl index 6121201..e36325b 100644 --- a/templates/tokens.tmpl +++ b/templates/tokens.tmpl @@ -14,7 +14,7 @@
-
+ + {{range $username, $user := .Users}} + + {{end}} + + + + +
+
+ +{{ template "footer.tmpl" . }} diff --git a/templates/header.tmpl b/templates/header.tmpl index 67921d3..0a54cac 100644 --- a/templates/header.tmpl +++ b/templates/header.tmpl @@ -26,6 +26,7 @@ Tunnels Add Tunnel Tokens + Clients {{ if $.User.IsAdmin }} Users {{ end }} diff --git a/ui_handler.go b/ui_handler.go index 0cc2b5a..5230c9c 100644 --- a/ui_handler.go +++ b/ui_handler.go @@ -235,10 +235,16 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request } case "/tokens": h.handleTokens(w, r, user, tokenData) + case "/clients": + h.handleClients(w, r, user, tokenData) case "/confirm-delete-token": h.confirmDeleteToken(w, r) case "/delete-token": h.deleteToken(w, r, tokenData) + case "/confirm-delete-client": + h.confirmDeleteClient(w, r) + case "/delete-client": + h.deleteClient(w, r, tokenData) case "/confirm-logout": data := &ConfirmData{ @@ -402,6 +408,66 @@ func (h *WebUiHandler) handleTokens(w http.ResponseWriter, r *http.Request, user } } +func (h *WebUiHandler) handleClients(w http.ResponseWriter, r *http.Request, user User, tokenData TokenData) { + + r.ParseForm() + + switch r.Method { + case "GET": + var users map[string]User + + // TODO: handle security checks in api + if user.IsAdmin { + users = h.db.GetUsers() + } else { + user, _ := h.db.GetUser(tokenData.Owner) + users = make(map[string]User) + users[tokenData.Owner] = user + } + + clients := make(map[string]DbClient) + + for _, user := range users { + for clientName, client := range user.Clients { + clients[clientName] = client + } + } + + templateData := struct { + User User + Users map[string]User + Clients map[string]DbClient + }{ + User: user, + Users: users, + Clients: clients, + } + + err := h.tmpl.ExecuteTemplate(w, "clients.tmpl", templateData) + if err != nil { + w.WriteHeader(500) + io.WriteString(w, err.Error()) + return + } + case "POST": + + owner := r.Form.Get("owner") + clientName := r.Form.Get("client-name") + + err := h.api.SetClient(tokenData, r.Form, owner, clientName) + if err != nil { + w.WriteHeader(500) + h.alertDialog(w, r, err.Error(), "/clients") + return + } + + http.Redirect(w, r, "/clients", 303) + default: + w.WriteHeader(405) + h.alertDialog(w, r, "Invalid method for tokens", "/tokens") + return + } +} func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { @@ -646,7 +712,6 @@ func (h *WebUiHandler) confirmDeleteToken(w http.ResponseWriter, r *http.Request return } } - func (h *WebUiHandler) deleteToken(w http.ResponseWriter, r *http.Request, tokenData TokenData) { r.ParseForm() @@ -660,6 +725,44 @@ func (h *WebUiHandler) deleteToken(w http.ResponseWriter, r *http.Request, token http.Redirect(w, r, "/tokens", 303) } +func (h *WebUiHandler) confirmDeleteClient(w http.ResponseWriter, r *http.Request) { + + r.ParseForm() + + owner := r.Form.Get("owner") + clientName := r.Form.Get("client-name") + + data := &ConfirmData{ + Head: h.headHtml, + Message: fmt.Sprintf("Are you sure you want to delete client %s for user %s?", clientName, owner), + ConfirmUrl: fmt.Sprintf("/delete-client?owner=%s&client-name=%s", owner, clientName), + CancelUrl: "/clients", + } + + err := h.tmpl.ExecuteTemplate(w, "confirm.tmpl", data) + if err != nil { + w.WriteHeader(500) + h.alertDialog(w, r, err.Error(), "/clients") + return + } +} +func (h *WebUiHandler) deleteClient(w http.ResponseWriter, r *http.Request, tokenData TokenData) { + + r.ParseForm() + + owner := r.Form.Get("owner") + clientName := r.Form.Get("client-name") + + err := h.api.DeleteClient(tokenData, owner, clientName) + if err != nil { + w.WriteHeader(500) + h.alertDialog(w, r, err.Error(), "/clients") + return + } + + http.Redirect(w, r, "/clients", 303) +} + func (h *WebUiHandler) alertDialog(w http.ResponseWriter, r *http.Request, message, redirectUrl string) error { err := h.tmpl.ExecuteTemplate(w, "alert.tmpl", &AlertData{ Head: h.headHtml, From 2907814539568e44c265d4a80a593dde685b3a8f Mon Sep 17 00:00:00 2001 From: Anders Pitman Date: Thu, 17 Feb 2022 13:47:03 -0700 Subject: [PATCH 07/10] Improve /tokens and /clients UI Show client name and make owner more clear. --- templates/clients.tmpl | 2 +- templates/tokens.tmpl | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/templates/clients.tmpl b/templates/clients.tmpl index cb2ae13..acda1d8 100644 --- a/templates/clients.tmpl +++ b/templates/clients.tmpl @@ -4,7 +4,7 @@ {{range $clientName, $client := $user.Clients}}
- {{$clientName}} ({{$username}}) + {{$clientName}} (Owner: {{$username}}) diff --git a/templates/tokens.tmpl b/templates/tokens.tmpl index e36325b..ba223cf 100644 --- a/templates/tokens.tmpl +++ b/templates/tokens.tmpl @@ -3,7 +3,11 @@ {{range $token, $tokenData := .Tokens}}
- {{$token}} ({{$tokenData.Owner}}) + {{ if eq $tokenData.Client "" }} + {{$token}} (Owner: {{$tokenData.Owner}}) (Client: Any) + {{ else }} + {{$token}} (Owner: {{$tokenData.Owner}}) (Client: {{$tokenData.Client}}) + {{ end }} Login link From 0eab8db4d64c5579eab199d7a6c12025185d6df4 Mon Sep 17 00:00:00 2001 From: Anders Pitman Date: Thu, 17 Feb 2022 14:40:17 -0700 Subject: [PATCH 08/10] Simplify client creation API Previously it was RESTful and required both a user and client name in order to PUT new clients. Now this information is taken from the token if possible (user is always available, client name may not be) and a simple POST /clients endpoint is provided. --- api.go | 51 +++++++++++++++++++++++++++++++++++++++++ client.go | 8 +++++-- cmd/boringproxy/main.go | 4 ---- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/api.go b/api.go index d248904..2140ca9 100644 --- a/api.go +++ b/api.go @@ -29,6 +29,7 @@ func NewApi(config *Config, db *Database, auth *Auth, tunMan *TunnelManager) *Ap mux.Handle("/tunnels", http.StripPrefix("/tunnels", http.HandlerFunc(api.handleTunnels))) mux.Handle("/users/", http.StripPrefix("/users", http.HandlerFunc(api.handleUsers))) mux.Handle("/tokens/", http.StripPrefix("/tokens", http.HandlerFunc(api.handleTokens))) + mux.Handle("/clients/", http.StripPrefix("/clients", http.HandlerFunc(api.handleClients))) return api } @@ -244,6 +245,56 @@ func (a *Api) handleTokens(w http.ResponseWriter, r *http.Request) { } } +func (a *Api) handleClients(w http.ResponseWriter, r *http.Request) { + + r.ParseForm() + + token, err := extractToken("access_token", r) + if err != nil { + w.WriteHeader(401) + w.Write([]byte("No token provided")) + return + } + + tokenData, exists := a.db.GetTokenData(token) + if !exists { + w.WriteHeader(403) + w.Write([]byte("Not authorized")) + return + } + + clientName := r.Form.Get("client-name") + if clientName == "" { + clientName = tokenData.Client + + if tokenData.Client == "" { + w.WriteHeader(400) + w.Write([]byte("Missing client-name parameter")) + return + } else { + clientName = tokenData.Client + } + } + + if tokenData.Client != "" && tokenData.Client != clientName { + w.WriteHeader(403) + io.WriteString(w, "Token does not have proper permissions") + return + } + + switch r.Method { + case "POST": + err := a.SetClient(tokenData, r.Form, tokenData.Owner, clientName) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(err.Error())) + } + default: + w.WriteHeader(405) + w.Write([]byte(err.Error())) + } +} + func (a *Api) GetTunnel(tokenData TokenData, params url.Values) (Tunnel, error) { domain := params.Get("domain") if domain == "" { diff --git a/client.go b/client.go index 1b7389a..3883c78 100644 --- a/client.go +++ b/client.go @@ -115,8 +115,12 @@ func NewClient(config *ClientConfig) (*Client, error) { func (c *Client) Run(ctx context.Context) error { - url := fmt.Sprintf("https://%s/api/users/%s/clients/%s", c.server, c.user, c.clientName) - clientReq, err := http.NewRequest("PUT", url, nil) + url := fmt.Sprintf("https://%s/api/clients/?client-name=%s", c.server, c.clientName) + if c.user != "" { + url = url + "&user=" + c.user + } + + clientReq, err := http.NewRequest("POST", url, nil) if err != nil { return errors.New(fmt.Sprintf("Failed to create request for URL %s", url)) } diff --git a/cmd/boringproxy/main.go b/cmd/boringproxy/main.go index 8619edb..ba37082 100644 --- a/cmd/boringproxy/main.go +++ b/cmd/boringproxy/main.go @@ -67,10 +67,6 @@ func main() { fail("-token is required") } - if *name == "" { - fail("-client-name is required") - } - config := &boringproxy.ClientConfig{ ServerAddr: *server, Token: *token, From 691afe1f8f6623238f514785386bcb46b68cd58e Mon Sep 17 00:00:00 2001 From: Anders Pitman Date: Thu, 17 Feb 2022 14:52:54 -0700 Subject: [PATCH 09/10] Implement /api/clients DELETE and fix bug Wasn't properly using user param. --- api.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/api.go b/api.go index 2140ca9..ae34dd4 100644 --- a/api.go +++ b/api.go @@ -265,8 +265,6 @@ func (a *Api) handleClients(w http.ResponseWriter, r *http.Request) { clientName := r.Form.Get("client-name") if clientName == "" { - clientName = tokenData.Client - if tokenData.Client == "" { w.WriteHeader(400) w.Write([]byte("Missing client-name parameter")) @@ -282,13 +280,25 @@ func (a *Api) handleClients(w http.ResponseWriter, r *http.Request) { return } + user := r.Form.Get("user") + if user == "" { + user = tokenData.Owner + } + switch r.Method { case "POST": - err := a.SetClient(tokenData, r.Form, tokenData.Owner, clientName) + err := a.SetClient(tokenData, r.Form, user, clientName) if err != nil { w.WriteHeader(500) w.Write([]byte(err.Error())) } + case "DELETE": + err := a.DeleteClient(tokenData, user, clientName) + if err != nil { + w.WriteHeader(500) + io.WriteString(w, err.Error()) + return + } default: w.WriteHeader(405) w.Write([]byte(err.Error())) From 72185f454c09273f5c675dff9d926d515504c7e6 Mon Sep 17 00:00:00 2001 From: Anders Pitman Date: Thu, 17 Feb 2022 14:55:14 -0700 Subject: [PATCH 10/10] Remove deprecated /api/users//clients --- api.go | 65 +++++++++++++--------------------------------------------- 1 file changed, 14 insertions(+), 51 deletions(-) diff --git a/api.go b/api.go index ae34dd4..d733fc0 100644 --- a/api.go +++ b/api.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "strconv" - "strings" ) type Api struct { @@ -149,61 +148,25 @@ func (a *Api) handleUsers(w http.ResponseWriter, r *http.Request) { return } - path := r.URL.Path - parts := strings.Split(path[1:], "/") - r.ParseForm() - if path == "/" { + if tokenData.Client != "" { + w.WriteHeader(403) + io.WriteString(w, "Token cannot be used to create users") + return + } - if tokenData.Client != "" { - w.WriteHeader(403) - io.WriteString(w, "Token cannot be used to create users") + switch r.Method { + case "POST": + err := a.CreateUser(tokenData, r.Form) + if err != nil { + w.WriteHeader(500) + io.WriteString(w, err.Error()) return } - - switch r.Method { - case "POST": - err := a.CreateUser(tokenData, r.Form) - if err != nil { - w.WriteHeader(500) - io.WriteString(w, err.Error()) - return - } - default: - w.WriteHeader(405) - io.WriteString(w, "Invalid method for /users") - return - } - } else if len(parts) == 3 && parts[1] == "clients" { - - ownerId := parts[0] - clientId := parts[2] - - if tokenData.Client != "" && clientId != tokenData.Client { - w.WriteHeader(403) - io.WriteString(w, "Token cannot be used to modify this user's clients") - return - } - - if r.Method == "PUT" { - err := a.SetClient(tokenData, r.Form, ownerId, clientId) - if err != nil { - w.WriteHeader(500) - io.WriteString(w, err.Error()) - return - } - } else if r.Method == "DELETE" { - err := a.DeleteClient(tokenData, ownerId, clientId) - if err != nil { - w.WriteHeader(500) - io.WriteString(w, err.Error()) - return - } - } - } else { - w.WriteHeader(400) - io.WriteString(w, "Invalid /users//clients request") + default: + w.WriteHeader(405) + io.WriteString(w, "Invalid method for /users") return } }