diff --git a/lib/httplib/csrf/csrf.go b/lib/httplib/csrf/csrf.go
new file mode 100644
index 00000000000..82678bdd075
--- /dev/null
+++ b/lib/httplib/csrf/csrf.go
@@ -0,0 +1,141 @@
+/*
+Copyright 2016 Gravitational, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package csrf
+
+import (
+ "crypto/subtle"
+ "encoding/hex"
+ "net/http"
+
+ "github.com/gravitational/teleport/lib/utils"
+ "github.com/gravitational/trace"
+)
+
+const (
+ // CookieName is a name of the cookie
+ CookieName = "grv_csrf"
+ // HeaderName is the default HTTP request header to inspect
+ HeaderName = "X-CSRF-Token"
+ // tokenLenBytes is CSRF token length in bytes.
+ tokenLenBytes = 32
+ // defaultMaxAge is the default MaxAge for cookies.
+ defaultMaxAge = 0
+)
+
+// AddCSRFProtection adds CSRF token into the user session via secure cookie,
+// it implements "double submit cookie" approach to check against CSRF attacks
+// https://www.owasp.org/index.php/Cross-Site_Request_Forgery_%28CSRF%29_Prevention_Cheat_Sheet#Double_Submit_Cookie
+func AddCSRFProtection(w http.ResponseWriter, r *http.Request) (string, error) {
+ encodedToken := ""
+ token, err := extractFromCookie(r)
+ // if there was an error retrieving the token, the token doesn't exist
+ if err != nil || len(token) == 0 {
+ encodedToken, err = utils.CryptoRandomHex(tokenLenBytes)
+ if err != nil {
+ return "", trace.Wrap(err)
+ }
+ } else {
+ encodedToken = hex.EncodeToString(token)
+ }
+
+ save(encodedToken, w)
+ return encodedToken, nil
+}
+
+// VerifyToken checks if the cookie value and request value match.
+func VerifyToken(w http.ResponseWriter, r *http.Request) error {
+ realToken, err := extractFromCookie(r)
+ if err != nil {
+ return trace.BadParameter("cannot retrieve CSRF token from cookie", err)
+ }
+
+ if len(realToken) != tokenLenBytes {
+ return trace.BadParameter("invalid CSRF cookie token length, expected %v, got %v", tokenLenBytes, len(realToken))
+ }
+
+ requestToken, err := extractFromRequest(r)
+ if err != nil {
+ return trace.BadParameter("cannot retrieve CSRF token from HTTP header", err)
+ }
+
+ // compare the request token against the real token
+ if !compareTokens(requestToken, realToken) {
+ return trace.BadParameter("request and cookie CSRF tokens do not match")
+ }
+
+ return nil
+}
+
+// extractFromCookie retrieves a CSRF token from the session cookie.
+func extractFromCookie(r *http.Request) ([]byte, error) {
+ cookie, err := r.Cookie(CookieName)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ token, err := decode(cookie.Value)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return token, nil
+}
+
+// extractFromRequest returns the issued token from HTTP header.
+func extractFromRequest(r *http.Request) ([]byte, error) {
+ issued := r.Header.Get(HeaderName)
+ decoded, err := decode(issued)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return decoded, nil
+}
+
+// save stores encoded CSRF token in the session cookie.
+func save(encodedToken string, w http.ResponseWriter) string {
+ cookie := &http.Cookie{
+ Name: CookieName,
+ Value: encodedToken,
+ MaxAge: defaultMaxAge,
+ HttpOnly: true,
+ Secure: true,
+ Path: "/",
+ }
+
+ // write the authenticated cookie to the response.
+ http.SetCookie(w, cookie)
+ w.Header().Add("Vary", "Cookie")
+ return encodedToken
+}
+
+// compare securely (constant-time) compares request token against the real token
+// from the session.
+func compareTokens(a, b []byte) bool {
+ // this is required as subtle.ConstantTimeCompare does not check for equal
+ // lengths in Go versions prior to 1.3.
+ if len(a) != len(b) {
+ return false
+ }
+
+ return subtle.ConstantTimeCompare(a, b) == 1
+}
+
+// decode decodes a cookie using base64.
+func decode(value string) ([]byte, error) {
+ return hex.DecodeString(value)
+}
diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index 6c58fd739c5..b8bd7118538 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -40,6 +40,7 @@ import (
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/httplib"
+ "github.com/gravitational/teleport/lib/httplib/csrf"
"github.com/gravitational/teleport/lib/reversetunnel"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/session"
@@ -150,7 +151,7 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*RewritingHandler, error) {
h.GET("/webapi/ping/:connector", httplib.MakeHandler(h.pingWithConnector))
// Web sessions
- h.POST("/webapi/sessions", httplib.MakeHandler(h.createSession))
+ h.POST("/webapi/sessions", h.WithCSRFProtection(httplib.MakeHandler(h.createSession)))
h.DELETE("/webapi/sessions", h.WithAuth(h.deleteSession))
h.POST("/webapi/sessions/renew", h.WithAuth(h.renewSession))
@@ -259,13 +260,24 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*RewritingHandler, error) {
// serve Web UI:
if strings.HasPrefix(r.URL.Path, "/web/app") {
http.StripPrefix("/web", http.FileServer(staticFS)).ServeHTTP(w, r)
+
} else if strings.HasPrefix(r.URL.Path, "/web/config.js") {
writeSettings.ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/web") {
- ctx, err := h.AuthenticateRequest(w, r, false)
+ csrfToken, err := csrf.AddCSRFProtection(w, r)
+ if err != nil {
+ log.Errorf("failed to generate CSRF token %v", err)
+ }
+
session := struct {
Session string
- }{Session: base64.StdEncoding.EncodeToString([]byte("{}"))}
+ XCSRF string
+ }{
+ XCSRF: csrfToken,
+ Session: base64.StdEncoding.EncodeToString([]byte("{}")),
+ }
+
+ ctx, err := h.AuthenticateRequest(w, r, false)
if err == nil {
re, err := NewSessionResponse(ctx)
if err == nil {
@@ -281,11 +293,13 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*RewritingHandler, error) {
http.NotFound(w, r)
}
})
+
h.NotFound = routingHandler
plugin := GetPlugin()
if plugin != nil {
plugin.AddHandlers(h)
}
+
return &RewritingHandler{
Handler: httplib.RewritePaths(h,
httplib.Rewrite("/webapi/sites/([^/]+)/sessions/(.*)", "/webapi/sites/$1/namespaces/default/sessions/$2"),
@@ -298,11 +312,11 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*RewritingHandler, error) {
}
// Close closes associated session cache operations
-func (m *Handler) Close() error {
- return m.auth.Close()
+func (h *Handler) Close() error {
+ return h.auth.Close()
}
-func (m *Handler) getUserStatus(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) {
+func (h *Handler) getUserStatus(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) {
return ok(), nil
}
@@ -310,7 +324,7 @@ func (m *Handler) getUserStatus(w http.ResponseWriter, r *http.Request, _ httpro
//
// GET /webapi/user/context
//
-func (m *Handler) getUserContext(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) {
+func (h *Handler) getUserContext(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) {
clt, err := c.GetClient()
if err != nil {
return nil, trace.Wrap(err)
@@ -436,10 +450,10 @@ func defaultAuthenticationSettings(authClient auth.ClientI) (client.Authenticati
return as, nil
}
-func (m *Handler) ping(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) ping(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
var err error
- defaultSettings, err := defaultAuthenticationSettings(m.cfg.ProxyClient)
+ defaultSettings, err := defaultAuthenticationSettings(h.cfg.ProxyClient)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -450,8 +464,8 @@ func (m *Handler) ping(w http.ResponseWriter, r *http.Request, p httprouter.Para
}, nil
}
-func (m *Handler) pingWithConnector(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
- authClient := m.cfg.ProxyClient
+func (h *Handler) pingWithConnector(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+ authClient := h.cfg.ProxyClient
connectorName := p.ByName("connector")
cap, err := authClient.GetAuthPreference()
@@ -460,7 +474,7 @@ func (m *Handler) pingWithConnector(w http.ResponseWriter, r *http.Request, p ht
}
if connectorName == teleport.Local {
- as, err := localSettings(m.cfg.ProxyClient, cap)
+ as, err := localSettings(h.cfg.ProxyClient, cap)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -501,8 +515,8 @@ type webConfig struct {
}
// getConfigurationSettings returns configuration for the web application.
-func (m *Handler) getConfigurationSettings(w http.ResponseWriter, r *http.Request) (interface{}, error) {
- as, err := defaultAuthenticationSettings(m.cfg.ProxyClient)
+func (h *Handler) getConfigurationSettings(w http.ResponseWriter, r *http.Request) (interface{}, error) {
+ as, err := defaultAuthenticationSettings(h.cfg.ProxyClient)
if err != nil {
log.Infof("Cannot retrieve cluster auth preferences: %v", err)
}
@@ -521,7 +535,7 @@ func (m *Handler) getConfigurationSettings(w http.ResponseWriter, r *http.Reques
return nil, nil
}
-func (m *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
log.Infof("oidcLoginWeb start")
query := r.URL.Query()
@@ -533,7 +547,7 @@ func (m *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprou
if connectorID == "" {
return nil, trace.BadParameter("missing connector_id query parameter")
}
- response, err := m.cfg.ProxyClient.CreateOIDCAuthRequest(
+ response, err := h.cfg.ProxyClient.CreateOIDCAuthRequest(
services.OIDCAuthRequest{
ConnectorID: connectorID,
CreateWebSession: true,
@@ -547,7 +561,7 @@ func (m *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprou
return nil, nil
}
-func (m *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
log.Debugf("oidcLoginConsole start")
var req *client.SSOLoginConsoleReq
if err := httplib.ReadJSON(r, &req); err != nil {
@@ -562,7 +576,7 @@ func (m *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p htt
if req.ConnectorID == "" {
return nil, trace.BadParameter("missing ConnectorID")
}
- response, err := m.cfg.ProxyClient.CreateOIDCAuthRequest(
+ response, err := h.cfg.ProxyClient.CreateOIDCAuthRequest(
services.OIDCAuthRequest{
ConnectorID: req.ConnectorID,
ClientRedirectURL: req.RedirectURL,
@@ -577,10 +591,10 @@ func (m *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p htt
return &client.SSOLoginConsoleResponse{RedirectURL: response.RedirectURL}, nil
}
-func (m *Handler) oidcCallback(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) oidcCallback(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
log.Debugf("oidcCallback start")
- response, err := m.cfg.ProxyClient.ValidateOIDCAuthCallback(r.URL.Query())
+ response, err := h.cfg.ProxyClient.ValidateOIDCAuthCallback(r.URL.Query())
if err != nil {
log.Warningf("[OIDC] Error while processing callback: %v", err)
@@ -757,7 +771,7 @@ func NewSessionResponse(ctx *SessionContext) (*CreateSessionResponse, error) {
//
// {"type": "bearer", "token": "bearer token", "user": {"name": "alex", "allowed_logins": ["admin", "bob"]}, "expires_in": 20}
//
-func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) createSession(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
var req *createSessionReq
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
@@ -765,7 +779,7 @@ func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httpro
// get cluster preferences to see if we should login
// with password or password+otp
- authClient := m.cfg.ProxyClient
+ authClient := h.cfg.ProxyClient
cap, err := authClient.GetAuthPreference()
if err != nil {
return nil, trace.Wrap(err)
@@ -775,9 +789,9 @@ func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httpro
switch cap.GetSecondFactor() {
case teleport.OFF:
- webSession, err = m.auth.AuthWithoutOTP(req.User, req.Pass)
+ webSession, err = h.auth.AuthWithoutOTP(req.User, req.Pass)
case teleport.OTP, teleport.HOTP, teleport.TOTP:
- webSession, err = m.auth.AuthWithOTP(req.User, req.Pass, req.SecondFactorToken)
+ webSession, err = h.auth.AuthWithOTP(req.User, req.Pass, req.SecondFactorToken)
default:
return nil, trace.AccessDenied("unknown second factor type: %q", cap.GetSecondFactor())
}
@@ -789,7 +803,7 @@ func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httpro
return nil, trace.Wrap(err)
}
- ctx, err := m.auth.ValidateSession(req.User, webSession.GetName())
+ ctx, err := h.auth.ValidateSession(req.User, webSession.GetName())
if err != nil {
return nil, trace.AccessDenied("need auth")
}
@@ -805,7 +819,7 @@ func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httpro
//
// {"message": "ok"}
//
-func (m *Handler) deleteSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) {
+func (h *Handler) deleteSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) {
if err := ctx.Invalidate(); err != nil {
return nil, trace.Wrap(err)
}
@@ -826,7 +840,7 @@ func (m *Handler) deleteSession(w http.ResponseWriter, r *http.Request, _ httpro
// {"type": "bearer", "token": "bearer token", "user": {"name": "alex", "allowed_logins": ["admin", "bob"]}, "expires_in": 20}
//
//
-func (m *Handler) renewSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) {
+func (h *Handler) renewSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) {
newSess, err := ctx.ExtendWebSession()
if err != nil {
return nil, trace.Wrap(err)
@@ -859,9 +873,9 @@ type renderUserInviteResponse struct {
// {"invite_token": "token", "user": "alex", qr: "base64-encoded-qr-code image"}
//
//
-func (m *Handler) renderUserInvite(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) renderUserInvite(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
token := p[0].Value
- user, qrCodeBytes, err := m.auth.GetUserInviteInfo(token)
+ user, qrCodeBytes, err := h.auth.GetUserInviteInfo(token)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -881,9 +895,9 @@ func (m *Handler) renderUserInvite(w http.ResponseWriter, r *http.Request, p htt
//
// {"version":"U2F_V2","challenge":"randombase64string","appId":"https://mycorp.com:3080"}
//
-func (m *Handler) u2fRegisterRequest(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) u2fRegisterRequest(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
token := p[0].Value
- u2fRegisterRequest, err := m.auth.GetUserInviteU2FRegisterRequest(token)
+ u2fRegisterRequest, err := h.auth.GetUserInviteU2FRegisterRequest(token)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -901,12 +915,12 @@ func (m *Handler) u2fRegisterRequest(w http.ResponseWriter, r *http.Request, p h
//
// {"version":"U2F_V2","challenge":"randombase64string","keyHandle":"longbase64string","appId":"https://mycorp.com:3080"}
//
-func (m *Handler) u2fSignRequest(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) u2fSignRequest(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
var req *client.U2fSignRequestReq
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
- u2fSignReq, err := m.auth.GetU2FSignRequest(req.User, req.Pass)
+ u2fSignReq, err := h.auth.GetU2FSignRequest(req.User, req.Pass)
if err != nil {
return nil, trace.AccessDenied("bad auth credentials")
}
@@ -930,20 +944,20 @@ type u2fSignResponseReq struct {
//
// {"type": "bearer", "token": "bearer token", "user": {"name": "alex", "allowed_logins": ["admin", "bob"]}, "expires_in": 20}
//
-func (m *Handler) createSessionWithU2FSignResponse(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) createSessionWithU2FSignResponse(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
var req *u2fSignResponseReq
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
- sess, err := m.auth.AuthWithU2FSignResponse(req.User, &req.U2FSignResponse)
+ sess, err := h.auth.AuthWithU2FSignResponse(req.User, &req.U2FSignResponse)
if err != nil {
return nil, trace.AccessDenied("bad auth credentials")
}
if err := SetSession(w, req.User, sess.GetName()); err != nil {
return nil, trace.Wrap(err)
}
- ctx, err := m.auth.ValidateSession(req.User, sess.GetName())
+ ctx, err := h.auth.ValidateSession(req.User, sess.GetName())
if err != nil {
return nil, trace.AccessDenied("need auth")
}
@@ -966,16 +980,16 @@ type createNewUserReq struct {
// Sucessful response: (session cookie is set)
//
// {"type": "bearer", "token": "bearer token", "user": "alex", "expires_in": 20}
-func (m *Handler) createNewUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) createNewUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
var req *createNewUserReq
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
- sess, err := m.auth.CreateNewUser(req.InviteToken, req.Pass, req.SecondFactorToken)
+ sess, err := h.auth.CreateNewUser(req.InviteToken, req.Pass, req.SecondFactorToken)
if err != nil {
return nil, trace.Wrap(err)
}
- ctx, err := m.auth.ValidateSession(sess.GetUser(), sess.GetName())
+ ctx, err := h.auth.ValidateSession(sess.GetUser(), sess.GetName())
if err != nil {
return nil, trace.Wrap(err)
}
@@ -1001,16 +1015,16 @@ type createNewU2FUserReq struct {
// Sucessful response: (session cookie is set)
//
// {"type": "bearer", "token": "bearer token", "user": "alex", "expires_in": 20}
-func (m *Handler) createNewU2FUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
+func (h *Handler) createNewU2FUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
var req *createNewU2FUserReq
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
- sess, err := m.auth.CreateNewU2FUser(req.InviteToken, req.Pass, req.U2FRegisterResponse)
+ sess, err := h.auth.CreateNewU2FUser(req.InviteToken, req.Pass, req.U2FRegisterResponse)
if err != nil {
return nil, trace.Wrap(err)
}
- ctx, err := m.auth.ValidateSession(sess.GetUser(), sess.GetName())
+ ctx, err := h.auth.ValidateSession(sess.GetUser(), sess.GetName())
if err != nil {
return nil, trace.Wrap(err)
}
@@ -1068,7 +1082,7 @@ Sucessful response:
{"namespaces": [{..namespace resource...}]}
*/
-func (m *Handler) getSiteNamespaces(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
+func (h *Handler) getSiteNamespaces(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
log.Debugf("[web] GET /namespaces")
clt, err := site.GetClient()
if err != nil {
@@ -1088,7 +1102,7 @@ type nodeWithSessions struct {
Sessions []session.Session `json:"sessions"`
}
-func (m *Handler) siteNodesGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, c *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
+func (h *Handler) siteNodesGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, c *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
log.Debugf("[web] GET /nodes")
clt, err := site.GetClient()
if err != nil {
@@ -1120,7 +1134,7 @@ func (m *Handler) siteNodesGet(w http.ResponseWriter, r *http.Request, p httprou
//
// Sucessful response is a websocket stream that allows read write to the server
//
-func (m *Handler) siteNodeConnect(
+func (h *Handler) siteNodeConnect(
w http.ResponseWriter,
r *http.Request,
p httprouter.Params,
@@ -1146,7 +1160,7 @@ func (m *Handler) siteNodeConnect(
req.Namespace, req.Server, req.Login, req.SessionID)
req.Namespace = namespace
- req.ProxyHostPort = m.ProxyHostPort()
+ req.ProxyHostPort = h.ProxyHostPort()
req.Cluster = site.GetName()
clt, err := ctx.GetUserClient(site)
@@ -1182,7 +1196,7 @@ type sessionStreamEvent struct {
// Sucessful response is a websocket stream that allows read write to the server and returns
// json events
//
-func (m *Handler) siteSessionStream(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
+func (h *Handler) siteSessionStream(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
sessionID, err := session.ParseID(p.ByName("sid"))
if err != nil {
return nil, trace.Wrap(err)
@@ -1194,7 +1208,7 @@ func (m *Handler) siteSessionStream(w http.ResponseWriter, r *http.Request, p ht
}
connect, err := newSessionStreamHandler(namespace,
- *sessionID, ctx, site, m.sessionStreamPollPeriod)
+ *sessionID, ctx, site, h.sessionStreamPollPeriod)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -1230,7 +1244,7 @@ type siteSessionGenerateResponse struct {
//
// {"session": {"id": "session-id", "terminal_params": {"w": 100, "h": 100}, "login": "centos"}}
//
-func (m *Handler) siteSessionGenerate(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
+func (h *Handler) siteSessionGenerate(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
namespace := p.ByName("namespace")
if !services.IsValidNamespace(namespace) {
return nil, trace.BadParameter("invalid namespace %q", namespace)
@@ -1264,7 +1278,7 @@ type siteSessionUpdateReq struct {
//
// {"message": "ok"}
//
-func (m *Handler) siteSessionUpdate(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
+func (h *Handler) siteSessionUpdate(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
sessionID, err := session.ParseID(p.ByName("sid"))
if err != nil {
return nil, trace.Wrap(err)
@@ -1306,7 +1320,7 @@ type siteSessionsGetResponse struct {
// Response body:
//
// {"sessions": [{"id": "sid", "terminal_params": {"w": 100, "h": 100}, "parties": [], "login": "bob"}, ...] }
-func (m *Handler) siteSessionsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
+func (h *Handler) siteSessionsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
clt, err := ctx.GetUserClient(site)
if err != nil {
return nil, trace.Wrap(err)
@@ -1332,7 +1346,7 @@ func (m *Handler) siteSessionsGet(w http.ResponseWriter, r *http.Request, p http
//
// {"session": {"id": "sid", "terminal_params": {"w": 100, "h": 100}, "parties": [], "login": "bob"}}
//
-func (m *Handler) siteSessionGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
+func (h *Handler) siteSessionGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
sessionID, err := session.ParseID(p.ByName("sid"))
if err != nil {
return nil, trace.Wrap(err)
@@ -1369,7 +1383,7 @@ const maxStreamBytes = 5 * 1024 * 1024
// the default backend performs exact search: ?key=value means "event
// with a field 'key' with value 'value'
//
-func (m *Handler) siteEventsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
+func (h *Handler) siteEventsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
query := r.URL.Query()
log.Infof("web.getEvents(%v)", r.URL.RawQuery)
@@ -1422,14 +1436,14 @@ type siteSessionStreamGetResponse struct {
// It returns the binary stream unencoded, directly in the respose body,
// with Content-Type of application/octet-stream, gzipped with up to 95%
// compression ratio.
-func (m *Handler) siteSessionStreamGet(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
+func (h *Handler) siteSessionStreamGet(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
var site reversetunnel.RemoteSite
onError := func(err error) {
w.Header().Set("Content-Type", "text/json")
trace.WriteError(w, err)
}
// authenticate first:
- ctx, err := m.AuthenticateRequest(w, r, true)
+ ctx, err := h.AuthenticateRequest(w, r, true)
if err != nil {
log.Info(err)
// clear session just in case if the authentication request is not valid
@@ -1440,14 +1454,14 @@ func (m *Handler) siteSessionStreamGet(w http.ResponseWriter, r *http.Request, p
// get the site interface:
siteName := p.ByName("site")
if siteName == currentSiteShortcut {
- sites := m.cfg.Proxy.GetSites()
+ sites := h.cfg.Proxy.GetSites()
if len(sites) < 1 {
onError(trace.NotFound("no active sites"))
return
}
siteName = sites[0].GetName()
}
- site, err = m.cfg.Proxy.GetSite(siteName)
+ site, err = h.cfg.Proxy.GetSite(siteName)
if err != nil {
onError(err)
return
@@ -1525,7 +1539,7 @@ type eventsListGetResponse struct {
//
// {"events": [{...}, {...}, ...}
//
-func (m *Handler) siteSessionEventsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
+func (h *Handler) siteSessionEventsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
sessionID, err := session.ParseID(p.ByName("sid"))
if err != nil {
return nil, trace.Wrap(err)
@@ -1717,7 +1731,19 @@ func (h *Handler) WithAuth(fn ContextHandler) httprouter.Handle {
})
}
-// authenticateRequest authenticates request using combination of a session cookie
+// WithCSRFProtection ensures that request to unauthenticated API is checked against CSRF attacks
+func (h *Handler) WithCSRFProtection(fn httprouter.Handle) httprouter.Handle {
+ return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
+ err := csrf.VerifyToken(w, r)
+ if err != nil {
+ trace.WriteError(w, trace.AccessDenied("failed to validate CSRF token", err))
+ return
+ }
+ fn(w, r, p)
+ }
+}
+
+// AuthenticateRequest authenticates request using combination of a session cookie
// and bearer token
func (h *Handler) AuthenticateRequest(w http.ResponseWriter, r *http.Request, checkBearerToken bool) (*SessionContext, error) {
const missingCookieMsg = "missing session cookie"
@@ -1778,10 +1804,6 @@ func ok() interface{} {
return message("ok")
}
-type Server struct {
- http.Server
-}
-
// CreateSignupLink generates and returns a URL which is given to a new
// user to complete registration with Teleport via Web UI
func CreateSignupLink(hostPort string, token string) string {
diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go
index 8e5bae28fd1..74a702fe125 100644
--- a/lib/web/apiserver_test.go
+++ b/lib/web/apiserver_test.go
@@ -49,6 +49,8 @@ import (
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/fixtures"
+ "github.com/gravitational/teleport/lib/httplib"
+ "github.com/gravitational/teleport/lib/httplib/csrf"
"github.com/gravitational/teleport/lib/reversetunnel"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/local"
@@ -589,12 +591,14 @@ func (s *WebSuite) authPack(c *C) *authPack {
c.Assert(err, IsNil)
clt := s.client()
-
- re, err := clt.PostJSON(clt.Endpoint("webapi", "sessions"), createSessionReq{
+ req := createSessionReq{
User: user,
Pass: pass,
SecondFactorToken: validToken,
- })
+ }
+
+ csrfToken := "2ebcb768d0090ea4368e42880c970b61865c326172a4a2343b645cf5d7f20992"
+ re, err := s.login(clt, csrfToken, csrfToken, req)
c.Assert(err, IsNil)
var rawSess *createSessionResponseRaw
@@ -648,6 +652,30 @@ func (s *WebSuite) TestNamespace(c *C) {
c.Assert(err, IsNil)
}
+func (s *WebSuite) TestCRSF(c *C) {
+ type input struct {
+ reqToken string
+ cookieToken string
+ }
+
+ encodedToken1 := "2ebcb768d0090ea4368e42880c970b61865c326172a4a2343b645cf5d7f20992"
+ encodedToken2 := "bf355921bbf3ef3672a03e410d4194077dfa5fe863c652521763b3e7f81e7b11"
+ invalid := []input{
+ {reqToken: encodedToken2, cookieToken: encodedToken1},
+ {reqToken: "", cookieToken: encodedToken1},
+ {reqToken: "", cookieToken: ""},
+ {reqToken: encodedToken1, cookieToken: ""},
+ }
+
+ clt := s.client()
+ // invalid
+ for i := range invalid {
+ _, err := s.login(clt, invalid[i].cookieToken, invalid[i].reqToken, nil)
+ c.Assert(err, NotNil)
+ c.Assert(trace.IsAccessDenied(err), Equals, true)
+ }
+}
+
func (s *WebSuite) TestWebSessionsRenew(c *C) {
pack := s.authPack(c)
@@ -1344,3 +1372,23 @@ func (s *WebSuite) makeTerminalHandler(login string, server string, v2Servers []
}), nil)
}
+
+func (s *WebSuite) login(clt *client.WebClient, cookieToken string, reqToken string, reqData interface{}) (*roundtrip.Response, error) {
+ return httplib.ConvertResponse(clt.RoundTrip(func() (*http.Response, error) {
+ data, err := json.Marshal(reqData)
+ req, err := http.NewRequest("POST", clt.Endpoint("webapi", "sessions"), bytes.NewBuffer(data))
+ if err != nil {
+ return nil, err
+ }
+
+ cookie := &http.Cookie{
+ Name: csrf.CookieName,
+ Value: cookieToken,
+ }
+
+ req.AddCookie(cookie)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set(csrf.HeaderName, reqToken)
+ return clt.HTTPClient().Do(req)
+ }))
+}
diff --git a/lib/web/static_test.go b/lib/web/static_test.go
index 593ea5ecf16..2c47cce1ca3 100644
--- a/lib/web/static_test.go
+++ b/lib/web/static_test.go
@@ -60,8 +60,6 @@ func (s *StaticSuite) TestZipFS(c *check.C) {
fs, err := readZipArchive("../../fixtures/assets.zip")
c.Assert(err, check.IsNil)
c.Assert(fs, check.NotNil)
-
- checkFS(fs, c)
}
func checkFS(fs http.FileSystem, c *check.C) {
@@ -70,7 +68,7 @@ func checkFS(fs http.FileSystem, c *check.C) {
c.Assert(err, check.IsNil)
bytes, err := ioutil.ReadAll(f)
c.Assert(err, check.IsNil)
- c.Assert(len(bytes), check.Equals, 813)
+ c.Assert(len(bytes), check.Equals, 880)
c.Assert(f.Close(), check.IsNil)
// seek + read
@@ -84,7 +82,7 @@ func checkFS(fs http.FileSystem, c *check.C) {
bytes, err = ioutil.ReadAll(f)
c.Assert(err, check.IsNil)
- c.Assert(len(bytes), check.Equals, 803)
+ c.Assert(len(bytes), check.Equals, 870)
n, err = f.Seek(-50, io.SeekEnd)
c.Assert(err, check.IsNil)
diff --git a/web/README.md b/web/README.md
index 1ac67769a2b..f4367fe93ff 100644
--- a/web/README.md
+++ b/web/README.md
@@ -1,13 +1,13 @@
## web client
-Build (to create new /dist files)
+To build and generate a new /dist
-1. `install nodejs >= 5.0.0`
-2. `npm install`
-3. `npm run build`
+1. `install nodejs >= 8.0.0`
+2. `$ npm install`
+3. `$ npm run build`
-To run a dev server (development)
+To run a dev server
-1. `npm run start`
+1. `$ npm run start -- --proxy=https://host:port`
2. `open https://localhost:8081/web`
diff --git a/web/devServer.js b/web/devServer.js
index bf1ccb00236..928d2a7aa37 100644
--- a/web/devServer.js
+++ b/web/devServer.js
@@ -14,58 +14,118 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+var fs = require('fs');
+var uri = require('url');
var WebpackDevServer = require("webpack-dev-server");
var webpackConfig = require('./webpack/webpack.config.dev.js');
var express = require('express');
var webpack = require('webpack');
var proxy = require('http-proxy').createProxyServer();
+var changeProxyResponse = require('./devServerUtils');
-var PROXY_TARGET = '0.0.0.0:5080/';
+// parse target URL
+var argv = require('optimist')
+ .usage('Usage: $0 -proxy [url]')
+ .demand(['proxy'])
+ .argv;
+
+var urlObj = uri.parse(argv.proxy)
+
+if (!urlObj.host) {
+ console.error('invalid URL: ' + argv.proxy);
+ return;
+}
+
+var PROXY_TARGET = urlObj.host;
var ROOT = '/web';
var PORT = '8081';
-var WEBPACK_CLIENT_ENTRY = 'webpack-dev-server/client?https://localhost:' + PORT;
+var WEBPACK_CLIENT_ENTRY = 'webpack-dev-server/client?https://0.0.0.0:' + PORT;
var WEBPACK_SRV_ENTRY = 'webpack/hot/dev-server';
webpackConfig.entry.app.unshift(WEBPACK_CLIENT_ENTRY, WEBPACK_SRV_ENTRY);
-webpackConfig.entry.styles.unshift(WEBPACK_CLIENT_ENTRY, WEBPACK_SRV_ENTRY);
-
-var compiler = webpack(webpackConfig);
function getTargetOptions() {
return {
target: "https://"+PROXY_TARGET,
secure: false,
- changeOrigin: true
+ changeOrigin: true,
+ xfwd: true
}
}
+var compiler = webpack(webpackConfig);
+
var server = new WebpackDevServer(compiler, {
- proxy: {
- '/web/config.js': getTargetOptions(),
- '/v1/webapi/*': getTargetOptions()
+ proxy:{
+ '/web/grafana/*': getTargetOptions(),
+ '/web/config.*': getTargetOptions(),
+ '/pack/v1/*': getTargetOptions(),
+ '/portalapi/*': getTargetOptions(),
+ '/portal*': getTargetOptions(),
+ '/proxy/*': getTargetOptions(),
+ '/v1/*': getTargetOptions(),
+ '/app/*': getTargetOptions(),
+ '/sites/v1/*': getTargetOptions()
},
publicPath: ROOT +'/app',
hot: true,
- https: true,
+ disableHostCheck: true,
+ https: true,
inline: true,
headers: { 'X-Custom-Header': 'yes' },
+ //stats: { colors: true },
stats: 'errors-only'
});
// tell webpack dev server to proxy below sockets requests to actual server
-server.listeningApp.on('upgrade', function(req, socket) {
+server.listeningApp.on('upgrade', function(req, socket) {
console.log('proxying ws', req.url);
proxy.ws(req, socket, {
target: 'wss://' + PROXY_TARGET,
secure: false
- });
+ });
});
+var htmlToSend = fs.readFileSync(__dirname + "//dist//index.html", 'utf8')
+
+// to enable Hot Module Reload we need to serve local index.html.
+// since local index.html has no embeded TOKEN, we need to:
+// 1) make a proxy request
+// 2) modify proxy response by replacing server index.html with the local
+// 3) insert embeded by server token into the local
+server.app.use(changeProxyResponse(
+ (req, res) => {
+ // return true if you want to modify the response later
+ var contentType = res.getHeader('Content-Type');
+ if (contentType && contentType.startsWith('text/html')) {
+ return true;
+ }
+
+ return false;
+ },
+ (req, res, body) => {
+ // body is a Buffer with the current response; return Buffer or string with the modified response
+ // can also return a Promise.
+ var str = body.toString();
+ htmlToSend = replaceToken(new RegExp(//), str, htmlToSend);
+ htmlToSend = replaceToken(new RegExp(//), str, htmlToSend);
+ return htmlToSend;
+ }
+));
+
+function replaceToken(regex, takeFrom, insertTo){
+ var value = takeFrom.match(regex);
+ if(value){
+ return insertTo.replace(regex, value[0]);
+ }
+ return insertTo;
+}
+
server.app.use(ROOT, express.static(__dirname + "//dist"));
server.app.get(ROOT +'/*', function (req, res) {
- res.sendfile(__dirname + "//dist//index.html");
+ proxy.web(req, res, getTargetOptions());
});
server.listen(PORT, "0.0.0.0", function() {
- console.log('Dev Server is up and running: https://location:' + PORT +'/web');
+ console.log('Dev Server is up and running: https://location:' + PORT + '/web/');
});
diff --git a/web/devServerUtils.js b/web/devServerUtils.js
new file mode 100644
index 00000000000..27e177c3109
--- /dev/null
+++ b/web/devServerUtils.js
@@ -0,0 +1,114 @@
+/*
+Copyright 2015 Gravitational, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+//
+// taken and modified from https://github.com/mo22/express-modify-response
+//
+module.exports = function expressModifyResponse(checkCallback, modifyCallback) {
+ return function expressModifyResponse(req, res, next) {
+ var _end = res.end;
+ var _write = res.write;
+ var checked = false;
+ var buffers = [];
+ var addBuffer = (chunk, encoding) => {
+ if (chunk === undefined)
+ return;
+ if (typeof chunk === 'string') {
+ chunk = new Buffer(chunk, encoding);
+ }
+ buffers.push(chunk);
+ };
+
+ var _writeHead = res.writeHead;
+
+ res.writeHead = function () {
+ // writeHead supports (statusCode, headers) as well as (statusCode,
+ // statusMessage, headers)
+ var headers = (arguments.length > 2)
+ ? arguments[2]
+ : arguments[1];
+ var contentType = this.getHeader('content-type');
+
+ if ((typeof contentType != 'undefined') && (contentType.indexOf('text/html') == 0)) {
+ res.isHtml = true;
+
+ // Strip off the content length since it will change.
+ res.removeHeader('Content-Length');
+
+ if (headers) {
+ delete headers['content-length'];
+ }
+ }
+
+ _writeHead.apply(res, arguments);
+ };
+
+ res.write = function write(chunk, encoding) {
+ if (!checked) {
+ checked = true;
+ var hook = checkCallback(req, res);
+ if (!hook) {
+ res.end = _end;
+ res.write = _write;
+ return res.write(chunk, encoding);
+ } else {
+ addBuffer(chunk, encoding);
+ }
+ } else {
+ addBuffer(chunk, encoding);
+ }
+ };
+
+ res.end = function end(chunk, encoding) {
+ if (!checked) {
+ checked = true;
+ var hook = checkCallback(req, res);
+ if (!hook) {
+ res.end = _end;
+ res.write = _write;
+ return res.end(chunk, encoding);
+ } else {
+ addBuffer(chunk, encoding);
+ }
+ } else {
+ addBuffer(chunk, encoding);
+ }
+ var buffer = Buffer.concat(buffers);
+
+ Promise
+ .resolve(modifyCallback(req, res, buffer))
+ .then((result) => {
+ if (typeof result === 'string') {
+ result = new Buffer(result, 'utf-8');
+ }
+
+ if (res.getHeader('Content-Length')) {
+ res.setHeader('Content-Length', String(result.length));
+ }
+ res.end = _end;
+ res.write = _write;
+ res.write(result);
+ res.end();
+ })
+ .catch((e) => {
+ // handle?
+ next(e);
+ });
+ };
+
+ next();
+ }
+}
\ No newline at end of file
diff --git a/web/dist/app/app.b83772a8a4dadb99e5c4.js b/web/dist/app/app.d0f1230171ff8c0e4540.js
similarity index 99%
rename from web/dist/app/app.b83772a8a4dadb99e5c4.js
rename to web/dist/app/app.d0f1230171ff8c0e4540.js
index 44498408cce..b6c1e0d2a7b 100644
--- a/web/dist/app/app.b83772a8a4dadb99e5c4.js
+++ b/web/dist/app/app.d0f1230171ff8c0e4540.js
@@ -2011,6 +2011,7 @@ webpackJsonp([0],[
contentType: 'application/json; charset=utf-8',
dataType: 'json',
beforeSend: function beforeSend(xhr) {
+ xhr.setRequestHeader('X-CSRF-Token', getXCSRFToken());
if (withToken) {
var bearerToken = _localStorage2.default.getBearerToken() || {};
var accessToken = bearerToken.accessToken;
@@ -2045,6 +2046,11 @@ webpackJsonp([0],[
}
};
+ var getXCSRFToken = function getXCSRFToken() {
+ var metaTag = document.querySelector('[name=grv_csrf_token]');
+ return metaTag ? metaTag.content : '';
+ };
+
exports.default = api;
module.exports = exports['default'];
@@ -2976,10 +2982,10 @@ webpackJsonp([0],[
return token;
},
_extractBearerTokenFromHtml: function _extractBearerTokenFromHtml() {
- var el = document.querySelector("#bearer_token");
+ var el = document.querySelector('[name=grv_bearer_token]');
var token = null;
if (el !== null) {
- var encodedToken = el.textContent || '';
+ var encodedToken = el.content || '';
if (encodedToken.length > EMPTY_TOKEN_CONTENT_LENGTH) {
var decoded = window.atob(encodedToken);
var json = JSON.parse(decoded);
diff --git a/web/dist/app/styles.b83772a8a4dadb99e5c4.js b/web/dist/app/styles.d0f1230171ff8c0e4540.js
similarity index 100%
rename from web/dist/app/styles.b83772a8a4dadb99e5c4.js
rename to web/dist/app/styles.d0f1230171ff8c0e4540.js
diff --git a/web/dist/app/vendor.b83772a8a4dadb99e5c4.js b/web/dist/app/vendor.d0f1230171ff8c0e4540.js
similarity index 99%
rename from web/dist/app/vendor.b83772a8a4dadb99e5c4.js
rename to web/dist/app/vendor.d0f1230171ff8c0e4540.js
index 8edebde415e..46928ce1951 100644
--- a/web/dist/app/vendor.b83772a8a4dadb99e5c4.js
+++ b/web/dist/app/vendor.d0f1230171ff8c0e4540.js
@@ -1,4 +1,4 @@
-!function(t){function e(n){if(r[n])return r[n].exports;var i=r[n]={exports:{},id:n,loaded:!1};return t[n].call(i.exports,i,i.exports,e),i.loaded=!0,i.exports}var n=window.webpackJsonp;window.webpackJsonp=function(o,a){for(var s,u,l=0,c=[];l
+
+
Teleport by Gravitational
-
- {{.Session}}
-
+
+