Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ $ chisel server --help
authfile with {"<user:pass>": [""]}. If unset, it will use the
environment variable AUTH.

--authurl, An optional URL to an external authentication service.
On each connection attempt chisel POSTs {"username": "...", "password": "..."}
as JSON to this URL. A 200 response must return a JSON array of address
regexes (in the same format as the values in --authfile) to grant access;
any non-200 response denies access. Use [""] or ["*"] to allow all addresses.
Supports all address-matching functionality of --authfile. Cannot be
combined with --authfile or --auth.

--keepalive, An optional keepalive interval. Since the underlying
transport is HTTP, in many instances we'll be traversing through
proxies, often these proxies will close idle connections. You must
Expand Down
9 changes: 9 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ var serverHelp = `
authfile with {"<user:pass>": [""]}. If unset, it will use the
environment variable AUTH.

--authurl, An optional URL to an external authentication service.
On each connection attempt chisel POSTs {"username": "...", "password": "..."}
as JSON to this URL. A 200 response must return a JSON array of address
regexes (in the same format as the values in --authfile) to grant access;
any non-200 response denies access. Use [""] or ["*"] to allow all addresses.
Supports all address-matching functionality of --authfile. Cannot be
combined with --authfile or --auth.

--keepalive, An optional keepalive interval. Since the underlying
transport is HTTP, in many instances we'll be traversing through
proxies, often these proxies will close idle connections. You must
Expand Down Expand Up @@ -185,6 +193,7 @@ func server(args []string) {
flags.StringVar(&config.KeyFile, "keyfile", "", "")
flags.StringVar(&config.AuthFile, "authfile", "", "")
flags.StringVar(&config.Auth, "auth", "", "")
flags.StringVar(&config.AuthURL, "authurl", "", "")
flags.DurationVar(&config.KeepAlive, "keepalive", 25*time.Second, "")
flags.StringVar(&config.Proxy, "proxy", "", "")
flags.StringVar(&config.Proxy, "backend", "", "")
Expand Down
26 changes: 22 additions & 4 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Config struct {
KeyFile string
AuthFile string
Auth string
AuthURL string
Proxy string
Socks5 bool
Reverse bool
Expand All @@ -45,6 +46,7 @@ type Server struct {
sessions *settings.Users
sshConfig *ssh.ServerConfig
users *settings.UserIndex
urlUsers *settings.URLUserIndex
}

var upgrader = websocket.Upgrader{
Expand All @@ -62,6 +64,9 @@ func NewServer(c *Config) (*Server, error) {
sessions: settings.NewUsers(),
}
server.Info = true
if c.AuthURL != "" && (c.AuthFile != "" || c.Auth != "") {
return nil, errors.New("--authurl cannot be combined with --authfile or --auth")
}
server.users = settings.NewUserIndex(server.Logger)
if c.AuthFile != "" {
if err := server.users.LoadUsers(c.AuthFile); err != nil {
Expand All @@ -75,6 +80,9 @@ func NewServer(c *Config) (*Server, error) {
server.users.AddUser(u)
}
}
if c.AuthURL != "" {
server.urlUsers = settings.NewURLUserIndex(c.AuthURL, server.Logger)
}

var pemBytes []byte
var err error
Expand Down Expand Up @@ -161,7 +169,7 @@ func (s *Server) Start(host, port string) error {
// and can be closed by cancelling the provided context
func (s *Server) StartContext(ctx context.Context, host, port string) error {
s.Infof("Fingerprint %s", s.fingerprint)
if s.users.Len() > 0 {
if s.users.Len() > 0 || s.urlUsers != nil {
s.Infof("User authentication enabled")
}
if s.reverseProxy != nil {
Expand Down Expand Up @@ -198,15 +206,25 @@ func (s *Server) GetFingerprint() string {
// authUser is responsible for validating the ssh user / password combination
func (s *Server) authUser(c ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
// check if user authentication is enabled and if not, allow all
if s.users.Len() == 0 {
if s.users.Len() == 0 && s.urlUsers == nil {
return nil, nil
}
// check the user exists and has matching password
n := c.User()
// URL-based auth: delegate credential check to external service
if s.urlUsers != nil {
user, err := s.urlUsers.GetUser(n, string(password))
if err != nil {
s.Debugf("Login failed for user: %s", n)
return nil, errors.New("Invalid authentication for username: " + n)
}
s.sessions.Set(string(c.SessionID()), user)
return nil, nil
}
// file/inline user auth
user, found := s.users.Get(n)
if !found || user.Pass != string(password) {
s.Debugf("Login failed for user: %s", n)
return nil, errors.New("Invalid authentication for username: %s")
return nil, errors.New("Invalid authentication for username: " + n)
}
// insert the user session map
// TODO this should probably have a lock on it given the map isn't thread-safe
Expand Down
2 changes: 1 addition & 1 deletion server/server_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func (s *Server) handleWebsocket(w http.ResponseWriter, req *http.Request) {
}
// pull the users from the session map
var user *settings.User
if s.users.Len() > 0 {
if s.users.Len() > 0 || s.urlUsers != nil {
sid := string(sshConn.SessionID())
u, ok := s.sessions.Get(sid)
if !ok {
Expand Down
75 changes: 75 additions & 0 deletions share/settings/user_url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package settings

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"

"github.com/jpillora/chisel/share/cio"
)

// URLUserIndex authenticates users against an external HTTP endpoint.
// On every login attempt it POSTs {"username": "...", "password": "..."}
// to the configured URL. A 200 response must contain a JSON array of address
// regexes (matching the values format of --authfile) to grant access; any
// other status code denies access.
type URLUserIndex struct {
*cio.Logger
url string
httpClient *http.Client
}

// NewURLUserIndex creates a URLUserIndex that will POST credentials to authURL.
func NewURLUserIndex(authURL string, logger *cio.Logger) *URLUserIndex {
return &URLUserIndex{
Logger: logger.Fork("url-users"),
url: authURL,
httpClient: &http.Client{},
}
}

// GetUser authenticates a user against the external URL and returns the
// resolved User (with compiled address regexes) on success, or an error on
// failure. An empty string or "*" in the address list grants full access
// (equivalent to UserAllowAll).
func (u *URLUserIndex) GetUser(name, pass string) (*User, error) {
body, err := json.Marshal(struct {
Username string `json:"username"`
Password string `json:"password"`
}{Username: name, Password: pass})
if err != nil {
return nil, err
}
resp, err := u.httpClient.Post(u.url, "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("auth denied (status %d)", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var addrStrs []string
if err := json.Unmarshal(raw, &addrStrs); err != nil {
return nil, fmt.Errorf("invalid JSON in auth response: %w", err)
}
addrs := make([]*regexp.Regexp, 0, len(addrStrs))
for _, s := range addrStrs {
if s == "" || s == "*" {
addrs = append(addrs, UserAllowAll)
} else {
re, err := regexp.Compile(s)
if err != nil {
return nil, fmt.Errorf("invalid address regex %q: %w", s, err)
}
addrs = append(addrs, re)
}
}
return &User{Name: name, Addrs: addrs}, nil
}
137 changes: 137 additions & 0 deletions share/settings/user_url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package settings

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/jpillora/chisel/share/cio"
)

func newTestURLUserIndex(t *testing.T, handler http.HandlerFunc) *URLUserIndex {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return NewURLUserIndex(srv.URL, cio.NewLogger("test"))
}

// assertPostJSON verifies the request is a JSON POST.
func assertPostJSON(t *testing.T, r *http.Request) {
t.Helper()
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", ct)
}
}

func TestURLUserIndex_200WithAddresses(t *testing.T) {
idx := newTestURLUserIndex(t, func(w http.ResponseWriter, r *http.Request) {
assertPostJSON(t, r)
json.NewEncoder(w).Encode([]string{`^127\.0\.0\.1:\d+$`, `^10\.`})
})
user, err := idx.GetUser("alice", "secret")
if err != nil {
t.Fatal(err)
}
if user.Name != "alice" {
t.Fatalf("expected name alice, got %s", user.Name)
}
if len(user.Addrs) != 2 {
t.Fatalf("expected 2 addrs, got %d", len(user.Addrs))
}
if !user.HasAccess("127.0.0.1:8080") {
t.Fatal("expected access to 127.0.0.1:8080")
}
if user.HasAccess("1.2.3.4:8080") {
t.Fatal("expected no access to 1.2.3.4:8080")
}
}

func TestURLUserIndex_200AllowAll(t *testing.T) {
idx := newTestURLUserIndex(t, func(w http.ResponseWriter, r *http.Request) {
assertPostJSON(t, r)
json.NewEncoder(w).Encode([]string{""})
})
user, err := idx.GetUser("bob", "pass")
if err != nil {
t.Fatal(err)
}
if !user.HasAccess("anything:1234") {
t.Fatal("expected allow-all access")
}
}

func TestURLUserIndex_200EmptyAddrs(t *testing.T) {
idx := newTestURLUserIndex(t, func(w http.ResponseWriter, r *http.Request) {
assertPostJSON(t, r)
json.NewEncoder(w).Encode([]string{})
})
user, err := idx.GetUser("carol", "pass")
if err != nil {
t.Fatal(err)
}
if user == nil {
t.Fatal("expected non-nil user")
}
if user.HasAccess("127.0.0.1:9000") {
t.Fatal("expected no access with empty addr list")
}
}

func TestURLUserIndex_NonOKDenied(t *testing.T) {
idx := newTestURLUserIndex(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
})
user, err := idx.GetUser("eve", "wrong")
if err == nil {
t.Fatal("expected error for non-200 response")
}
if user != nil {
t.Fatal("expected nil user on denial")
}
}

func TestURLUserIndex_InvalidJSON(t *testing.T) {
idx := newTestURLUserIndex(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("not-json"))
})
user, err := idx.GetUser("frank", "pass")
if err == nil {
t.Fatal("expected error for invalid JSON body")
}
if user != nil {
t.Fatal("expected nil user on parse error")
}
}

func TestURLUserIndex_RequestFormat(t *testing.T) {
var gotBody []byte
var gotContentType string
idx := newTestURLUserIndex(t, func(w http.ResponseWriter, r *http.Request) {
gotContentType = r.Header.Get("Content-Type")
gotBody, _ = io.ReadAll(r.Body)
json.NewEncoder(w).Encode([]string{""})
})
_, err := idx.GetUser("grace", "hunter2")
if err != nil {
t.Fatal(err)
}
if gotContentType != "application/json" {
t.Fatalf("expected Content-Type application/json, got %q", gotContentType)
}
var payload struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.Unmarshal(gotBody, &payload); err != nil {
t.Fatalf("could not parse request body: %v", err)
}
if payload.Username != "grace" || payload.Password != "hunter2" {
t.Fatalf("unexpected payload: %+v", payload)
}
}
Loading