chore: support signed token query param for web terminal (#7197)

* chore: add endpoint to get token for web terminal

* chore: support signed token query param for web terminal
This commit is contained in:
Dean Sheather
2023-04-20 23:59:45 +00:00
committed by GitHub
parent ac3c530283
commit 68667323f3
25 changed files with 886 additions and 164 deletions
+67
View File
@@ -159,6 +159,48 @@ const docTemplate = `{
}
}
},
"/applications/reconnecting-pty-signed-token": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Applications Enterprise"
],
"summary": "Issue signed app token for reconnecting PTY",
"operationId": "issue-signed-app-token-for-reconnecting-pty",
"parameters": [
{
"description": "Issue reconnecting PTY signed token request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenResponse"
}
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/audit": {
"get": {
"security": [
@@ -7451,6 +7493,31 @@ const docTemplate = `{
}
}
},
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
"type": "object",
"required": [
"agentID",
"url"
],
"properties": {
"agentID": {
"type": "string",
"format": "uuid"
},
"url": {
"description": "URL is the URL of the reconnecting-pty endpoint you are connecting to.",
"type": "string"
}
}
},
"codersdk.IssueReconnectingPTYSignedTokenResponse": {
"type": "object",
"properties": {
"signed_token": {
"type": "string"
}
}
},
"codersdk.JobErrorCode": {
"type": "string",
"enum": [
+58
View File
@@ -131,6 +131,42 @@
}
}
},
"/applications/reconnecting-pty-signed-token": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Applications Enterprise"],
"summary": "Issue signed app token for reconnecting PTY",
"operationId": "issue-signed-app-token-for-reconnecting-pty",
"parameters": [
{
"description": "Issue reconnecting PTY signed token request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenResponse"
}
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/audit": {
"get": {
"security": [
@@ -6682,6 +6718,28 @@
}
}
},
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
"type": "object",
"required": ["agentID", "url"],
"properties": {
"agentID": {
"type": "string",
"format": "uuid"
},
"url": {
"description": "URL is the URL of the reconnecting-pty endpoint you are connecting to.",
"type": "string"
}
}
},
"codersdk.IssueReconnectingPTYSignedTokenResponse": {
"type": "object",
"properties": {
"signed_token": {
"type": "string"
}
}
},
"codersdk.JobErrorCode": {
"type": "string",
"enum": ["MISSING_TEMPLATE_PARAMETER", "REQUIRED_TEMPLATE_VARIABLES"],
-4
View File
@@ -1701,10 +1701,6 @@ func (q *querier) GetWorkspaceProxyByName(ctx context.Context, name string) (dat
return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByName)(ctx, name)
}
func (q *querier) GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (database.WorkspaceProxy, error) {
return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByHostname)(ctx, hostname)
}
func (q *querier) InsertWorkspaceProxy(ctx context.Context, arg database.InsertWorkspaceProxyParams) (database.WorkspaceProxy, error) {
return insert(q.log, q.auth, rbac.ResourceWorkspaceProxy, q.db.InsertWorkspaceProxy)(ctx, arg)
}
+7
View File
@@ -438,3 +438,10 @@ func (q *querier) InsertParameterSchema(ctx context.Context, arg database.Insert
}
return q.db.InsertParameterSchema(ctx, arg)
}
func (q *querier) GetWorkspaceProxyByHostname(ctx context.Context, params database.GetWorkspaceProxyByHostnameParams) (database.WorkspaceProxy, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
return database.WorkspaceProxy{}, err
}
return q.db.GetWorkspaceProxyByHostname(ctx, params)
}
+13 -11
View File
@@ -24,7 +24,7 @@ import (
"github.com/coder/coder/coderd/util/slice"
)
var validProxyByHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
var validProxyByHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// FakeDatabase is helpful for knowing if the underlying db is an in memory fake
// database. This is only in the databasefake package, so will only be used
@@ -5142,34 +5142,36 @@ func (q *fakeQuerier) GetWorkspaceProxyByName(_ context.Context, name string) (d
return database.WorkspaceProxy{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, hostname string) (database.WorkspaceProxy, error) {
func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, params database.GetWorkspaceProxyByHostnameParams) (database.WorkspaceProxy, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
// Return zero rows if this is called with a non-sanitized hostname. The SQL
// version of this query does the same thing.
if !validProxyByHostnameRegex.MatchString(hostname) {
if !validProxyByHostnameRegex.MatchString(params.Hostname) {
return database.WorkspaceProxy{}, sql.ErrNoRows
}
// This regex matches the SQL version.
accessURLRegex := regexp.MustCompile(`[^:]*://` + regexp.QuoteMeta(hostname) + `([:/]?.)*`)
accessURLRegex := regexp.MustCompile(`[^:]*://` + regexp.QuoteMeta(params.Hostname) + `([:/]?.)*`)
for _, proxy := range q.workspaceProxies {
if proxy.Deleted {
continue
}
if accessURLRegex.MatchString(proxy.Url) {
if params.AllowAccessUrl && accessURLRegex.MatchString(proxy.Url) {
return proxy, nil
}
// Compile the app hostname regex. This is slow sadly.
wildcardRegexp, err := httpapi.CompileHostnamePattern(proxy.WildcardHostname)
if err != nil {
return database.WorkspaceProxy{}, xerrors.Errorf("compile hostname pattern %q for proxy %q (%s): %w", proxy.WildcardHostname, proxy.Name, proxy.ID.String(), err)
}
if _, ok := httpapi.ExecuteHostnamePattern(wildcardRegexp, hostname); ok {
return proxy, nil
if params.AllowWildcardHostname {
wildcardRegexp, err := httpapi.CompileHostnamePattern(proxy.WildcardHostname)
if err != nil {
return database.WorkspaceProxy{}, xerrors.Errorf("compile hostname pattern %q for proxy %q (%s): %w", proxy.WildcardHostname, proxy.Name, proxy.ID.String(), err)
}
if _, ok := httpapi.ExecuteHostnamePattern(wildcardRegexp, params.Hostname); ok {
return proxy, nil
}
}
}
+59 -25
View File
@@ -160,44 +160,74 @@ func TestProxyByHostname(t *testing.T) {
}
cases := []struct {
name string
testHostname string
matchProxyName string
name string
testHostname string
allowAccessURL bool
allowWildcardHost bool
matchProxyName string
}{
{
name: "NoMatch",
testHostname: "test.com",
matchProxyName: "",
name: "NoMatch",
testHostname: "test.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "MatchAccessURL",
testHostname: "one.coder.com",
matchProxyName: "one",
name: "MatchAccessURL",
testHostname: "one.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "one",
},
{
name: "MatchWildcard",
testHostname: "something.wildcard.one.coder.com",
matchProxyName: "one",
name: "MatchWildcard",
testHostname: "something.wildcard.one.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "one",
},
{
name: "MatchSuffix",
testHostname: "something--suffix.two.coder.com",
matchProxyName: "two",
name: "MatchSuffix",
testHostname: "something--suffix.two.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "two",
},
{
name: "ValidateHostname/1",
testHostname: ".*ne.coder.com",
matchProxyName: "",
name: "ValidateHostname/1",
testHostname: ".*ne.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "ValidateHostname/2",
testHostname: "https://one.coder.com",
matchProxyName: "",
name: "ValidateHostname/2",
testHostname: "https://one.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "ValidateHostname/3",
testHostname: "one.coder.com:8080/hello",
matchProxyName: "",
name: "ValidateHostname/3",
testHostname: "one.coder.com:8080/hello",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "IgnoreAccessURLMatch",
testHostname: "one.coder.com",
allowAccessURL: false,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "IgnoreWildcardMatch",
testHostname: "hi.wildcard.one.coder.com",
allowAccessURL: true,
allowWildcardHost: false,
matchProxyName: "",
},
}
@@ -206,7 +236,11 @@ func TestProxyByHostname(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname)
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), database.GetWorkspaceProxyByHostnameParams{
Hostname: c.testHostname,
AllowAccessUrl: c.allowAccessURL,
AllowWildcardHostname: c.allowWildcardHost,
})
if c.matchProxyName == "" {
require.ErrorIs(t, err, sql.ErrNoRows)
require.Empty(t, proxy)
+1 -1
View File
@@ -156,7 +156,7 @@ type sqlcQuerier interface {
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
// this query. The scheme, port and path should be stripped.
//
GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (WorkspaceProxy, error)
GetWorkspaceProxyByHostname(ctx context.Context, arg GetWorkspaceProxyByHostnameParams) (WorkspaceProxy, error)
GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error)
GetWorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error)
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
+59 -25
View File
@@ -165,44 +165,74 @@ func TestProxyByHostname(t *testing.T) {
}
cases := []struct {
name string
testHostname string
matchProxyName string
name string
testHostname string
allowAccessURL bool
allowWildcardHost bool
matchProxyName string
}{
{
name: "NoMatch",
testHostname: "test.com",
matchProxyName: "",
name: "NoMatch",
testHostname: "test.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "MatchAccessURL",
testHostname: "one.coder.com",
matchProxyName: "one",
name: "MatchAccessURL",
testHostname: "one.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "one",
},
{
name: "MatchWildcard",
testHostname: "something.wildcard.one.coder.com",
matchProxyName: "one",
name: "MatchWildcard",
testHostname: "something.wildcard.one.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "one",
},
{
name: "MatchSuffix",
testHostname: "something--suffix.two.coder.com",
matchProxyName: "two",
name: "MatchSuffix",
testHostname: "something--suffix.two.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "two",
},
{
name: "ValidateHostname/1",
testHostname: ".*ne.coder.com",
matchProxyName: "",
name: "ValidateHostname/1",
testHostname: ".*ne.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "ValidateHostname/2",
testHostname: "https://one.coder.com",
matchProxyName: "",
name: "ValidateHostname/2",
testHostname: "https://one.coder.com",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "ValidateHostname/3",
testHostname: "one.coder.com:8080/hello",
matchProxyName: "",
name: "ValidateHostname/3",
testHostname: "one.coder.com:8080/hello",
allowAccessURL: true,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "IgnoreAccessURLMatch",
testHostname: "one.coder.com",
allowAccessURL: false,
allowWildcardHost: true,
matchProxyName: "",
},
{
name: "IgnoreWildcardMatch",
testHostname: "hi.wildcard.one.coder.com",
allowAccessURL: true,
allowWildcardHost: false,
matchProxyName: "",
},
}
@@ -211,7 +241,11 @@ func TestProxyByHostname(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname)
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), database.GetWorkspaceProxyByHostnameParams{
Hostname: c.testHostname,
AllowAccessUrl: c.allowAccessURL,
AllowWildcardHostname: c.allowWildcardHost,
})
if c.matchProxyName == "" {
require.ErrorIs(t, err, sql.ErrNoRows)
require.Empty(t, proxy)
+17 -5
View File
@@ -2871,27 +2871,39 @@ WHERE
--
-- Periods don't need to be escaped because they're not special characters
-- in SQL matches unlike regular expressions.
$1 :: text SIMILAR TO '[a-zA-Z0-9.-]+' AND
$1 :: text SIMILAR TO '[a-zA-Z0-9._-]+' AND
deleted = false AND
-- Validate that the hostname matches either the wildcard hostname or the
-- access URL (ignoring scheme, port and path).
(
url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' OR
$1 :: text LIKE replace(wildcard_hostname, '*', '%')
(
$2 :: bool = true AND
url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*'
) OR
(
$3 :: bool = true AND
$1 :: text LIKE replace(wildcard_hostname, '*', '%')
)
)
LIMIT
1
`
type GetWorkspaceProxyByHostnameParams struct {
Hostname string `db:"hostname" json:"hostname"`
AllowAccessUrl bool `db:"allow_access_url" json:"allow_access_url"`
AllowWildcardHostname bool `db:"allow_wildcard_hostname" json:"allow_wildcard_hostname"`
}
// Finds a workspace proxy that has an access URL or app hostname that matches
// the provided hostname. This is to check if a hostname matches any workspace
// proxy.
//
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
// this query. The scheme, port and path should be stripped.
func (q *sqlQuerier) GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (WorkspaceProxy, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceProxyByHostname, hostname)
func (q *sqlQuerier) GetWorkspaceProxyByHostname(ctx context.Context, arg GetWorkspaceProxyByHostnameParams) (WorkspaceProxy, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceProxyByHostname, arg.Hostname, arg.AllowAccessUrl, arg.AllowWildcardHostname)
var i WorkspaceProxy
err := row.Scan(
&i.ID,
+9 -3
View File
@@ -77,14 +77,20 @@ WHERE
--
-- Periods don't need to be escaped because they're not special characters
-- in SQL matches unlike regular expressions.
@hostname :: text SIMILAR TO '[a-zA-Z0-9.-]+' AND
@hostname :: text SIMILAR TO '[a-zA-Z0-9._-]+' AND
deleted = false AND
-- Validate that the hostname matches either the wildcard hostname or the
-- access URL (ignoring scheme, port and path).
(
url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*' OR
@hostname :: text LIKE replace(wildcard_hostname, '*', '%')
(
@allow_access_url :: bool = true AND
url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*'
) OR
(
@allow_wildcard_hostname :: bool = true AND
@hostname :: text LIKE replace(wildcard_hostname, '*', '%')
)
)
LIMIT
1;
+86 -37
View File
@@ -1,15 +1,18 @@
package coderd
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbauthz"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
@@ -73,45 +76,28 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
})
return
}
// Force the redirect URI to use the same scheme as the access URL for
// security purposes.
u.Scheme = api.AccessURL.Scheme
ok := false
if api.AppHostnameRegex != nil {
_, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host)
u.Scheme, err = api.ValidWorkspaceAppHostname(ctx, u.Host, ValidWorkspaceAppHostnameOpts{
// Allow all hosts except primary access URL since we don't need app
// tokens on the primary dashboard URL.
AllowPrimaryAccessURL: false,
AllowPrimaryWildcard: true,
AllowProxyAccessURL: true,
AllowProxyWildcard: true,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to verify redirect_uri query parameter.",
Detail: err.Error(),
})
return
}
// Ensure that the redirect URI is a subdomain of api.Hostname and is a
// valid app subdomain.
if !ok {
proxy, err := api.Database.GetWorkspaceProxyByHostname(ctx, u.Hostname())
if xerrors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "The redirect_uri query parameter must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.",
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get workspace proxy by redirect_uri.",
Detail: err.Error(),
})
return
}
proxyURL, err := url.Parse(proxy.Url)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to parse workspace proxy URL.",
Detail: xerrors.Errorf("parse proxy URL %q: %w", proxy.Url, err).Error(),
})
return
}
// Force the redirect URI to use the same scheme as the proxy access URL
// for security purposes.
u.Scheme = proxyURL.Scheme
if u.Scheme == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid redirect_uri.",
Detail: "The redirect_uri query parameter must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.",
})
return
}
// Create the application_connect-scoped API key with the same lifetime as
@@ -156,3 +142,66 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
u.RawQuery = q.Encode()
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
}
type ValidWorkspaceAppHostnameOpts struct {
AllowPrimaryAccessURL bool
AllowPrimaryWildcard bool
AllowProxyAccessURL bool
AllowProxyWildcard bool
}
// ValidWorkspaceAppHostname checks if the given host is a valid workspace app
// hostname based on the provided options. It returns a scheme to force on
// success. If the hostname is not valid or doesn't match, an empty string is
// returned. Any error returned is a 500 error.
//
// For hosts that match a wildcard app hostname, the scheme is forced to be the
// corresponding access URL scheme.
func (api *API) ValidWorkspaceAppHostname(ctx context.Context, host string, opts ValidWorkspaceAppHostnameOpts) (string, error) {
if opts.AllowPrimaryAccessURL && (host == api.AccessURL.Hostname() || host == api.AccessURL.Host) {
// Force the redirect URI to have the same scheme as the access URL for
// security purposes.
return api.AccessURL.Scheme, nil
}
if opts.AllowPrimaryWildcard && api.AppHostnameRegex != nil {
_, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, host)
if ok {
// Force the redirect URI to have the same scheme as the access URL
// for security purposes.
return api.AccessURL.Scheme, nil
}
}
// Ensure that the redirect URI is a subdomain of api.Hostname and is a
// valid app subdomain.
if opts.AllowProxyAccessURL || opts.AllowProxyWildcard {
// Strip the port for the database query.
host = strings.Split(host, ":")[0]
// nolint:gocritic // system query
systemCtx := dbauthz.AsSystemRestricted(ctx)
proxy, err := api.Database.GetWorkspaceProxyByHostname(systemCtx, database.GetWorkspaceProxyByHostnameParams{
Hostname: host,
AllowAccessUrl: opts.AllowProxyAccessURL,
AllowWildcardHostname: opts.AllowProxyWildcard,
})
if xerrors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", xerrors.Errorf("get workspace proxy by hostname %q: %w", host, err)
}
proxyURL, err := url.Parse(proxy.Url)
if err != nil {
return "", xerrors.Errorf("parse proxy URL %q: %w", proxy.Url, err)
}
// Force the redirect URI to use the same scheme as the proxy access URL
// for security purposes.
return proxyURL.Scheme, nil
}
return "", nil
}
+90 -38
View File
@@ -22,6 +22,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/rbac"
@@ -46,43 +47,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
t.Skip("ConPTY appears to be inconsistent on Windows.")
}
appDetails := setupProxyTest(t, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Run the test against the path app hostname since that's where the
// reconnecting-pty proxy server we want to test is mounted.
client := appDetails.AppClient(t)
conn, err := client.WorkspaceAgentReconnectingPTY(ctx, appDetails.Agent.ID, uuid.New(), 80, 80, "/bin/bash")
require.NoError(t, err)
defer conn.Close()
// First attempt to resize the TTY.
// The websocket will close if it fails!
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
Height: 250,
Width: 250,
})
require.NoError(t, err)
_, err = conn.Write(data)
require.NoError(t, err)
bufRead := bufio.NewReader(conn)
// Brief pause to reduce the likelihood that we send keystrokes while
// the shell is simultaneously sending a prompt.
time.Sleep(100 * time.Millisecond)
data, err = json.Marshal(codersdk.ReconnectingPTYRequest{
Data: "echo test\r\n",
})
require.NoError(t, err)
_, err = conn.Write(data)
require.NoError(t, err)
expectLine := func(matcher func(string) bool) {
expectLine := func(r *bufio.Reader, matcher func(string) bool) {
for {
line, err := bufRead.ReadString('\n')
line, err := r.ReadString('\n')
require.NoError(t, err)
if matcher(line) {
break
@@ -96,8 +63,93 @@ func Run(t *testing.T, factory DeploymentFactory) {
return strings.Contains(line, "test") && !strings.Contains(line, "echo")
}
expectLine(matchEchoCommand)
expectLine(matchEchoOutput)
t.Run("OK", func(t *testing.T) {
appDetails := setupProxyTest(t, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Run the test against the path app hostname since that's where the
// reconnecting-pty proxy server we want to test is mounted.
client := appDetails.AppClient(t)
conn, err := client.WorkspaceAgentReconnectingPTY(ctx, appDetails.Agent.ID, uuid.New(), 80, 80, "/bin/bash")
require.NoError(t, err)
defer conn.Close()
// First attempt to resize the TTY.
// The websocket will close if it fails!
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
Height: 250,
Width: 250,
})
require.NoError(t, err)
_, err = conn.Write(data)
require.NoError(t, err)
bufRead := bufio.NewReader(conn)
// Brief pause to reduce the likelihood that we send keystrokes while
// the shell is simultaneously sending a prompt.
time.Sleep(100 * time.Millisecond)
data, err = json.Marshal(codersdk.ReconnectingPTYRequest{
Data: "echo test\r\n",
})
require.NoError(t, err)
_, err = conn.Write(data)
require.NoError(t, err)
expectLine(bufRead, matchEchoCommand)
expectLine(bufRead, matchEchoOutput)
})
t.Run("SignedTokenQueryParameter", func(t *testing.T) {
t.Parallel()
appDetails := setupProxyTest(t, nil)
if appDetails.AppHostIsPrimary {
t.Skip("Tickets are not used for terminal requests on the primary.")
}
u := *appDetails.PathAppBaseURL
if u.Scheme == "http" {
u.Scheme = "ws"
} else {
u.Scheme = "wss"
}
u.Path = fmt.Sprintf("/api/v2/workspaceagents/%s/pty", appDetails.Agent.ID.String())
ctx := testutil.Context(t, testutil.WaitLong)
issueRes, err := appDetails.SDKClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
URL: u.String(),
AgentID: appDetails.Agent.ID,
})
require.NoError(t, err)
// Try to connect to the endpoint with the signed token and no other
// authentication.
q := u.Query()
q.Set("reconnect", uuid.NewString())
q.Set("height", strconv.Itoa(24))
q.Set("width", strconv.Itoa(80))
q.Set("command", `/bin/sh -c "echo test"`)
q.Set(codersdk.SignedAppTokenQueryParameter, issueRes.SignedToken)
u.RawQuery = q.Encode()
//nolint:bodyclose
wsConn, res, err := websocket.Dial(ctx, u.String(), nil)
if !assert.NoError(t, err) {
dump, err := httputil.DumpResponse(res, true)
if err == nil {
t.Log(string(dump))
}
return
}
defer wsConn.Close(websocket.StatusNormalClosure, "")
conn := websocket.NetConn(ctx, wsConn, websocket.MessageBinary)
bufRead := bufio.NewReader(conn)
expectLine(bufRead, matchEchoOutput)
})
})
t.Run("WorkspaceAppsProxyPath", func(t *testing.T) {
+1 -1
View File
@@ -594,7 +594,7 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
BasePath: r.URL.Path,
AgentNameOrID: chi.URLParam(r, "workspaceagent"),
},
AppPath: r.URL.Path,
AppPath: "",
AppQuery: "",
})
if !ok {
+1 -6
View File
@@ -1,8 +1,3 @@
package workspaceapps_test
// NOTE: for now, app proxying tests are still in their old locations, pending
// being moved to their own package.
//
// See:
// - coderd/workspaceapps_test.go
// - coderd/workspaceagents_test.go (for PTY)
// App tests can be found in the apptest package.
+21 -4
View File
@@ -224,13 +224,30 @@ func (k SecurityKey) DecryptAPIKey(encryptedAPIKey string) (string, error) {
return payload.APIKey, nil
}
// FromRequest returns the signed token from the request, if it exists and is
// valid. The caller must check that the token matches the request.
func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
// Get the existing token from the request.
tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
if err == nil {
token, err := key.VerifySignedToken(tokenCookie.Value)
// Get the token string from the request. We usually use a cookie for this,
// but for web terminal we also support a query parameter to support
// cross-domain terminal access.
tokenStr := ""
tokenCookie, cookieErr := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
if cookieErr == nil {
tokenStr = tokenCookie.Value
} else {
tokenStr = r.URL.Query().Get(codersdk.SignedAppTokenQueryParameter)
}
if tokenStr != "" {
token, err := key.VerifySignedToken(tokenStr)
if err == nil {
req := token.Request.Normalize()
if cookieErr != nil && req.AccessMethod != AccessMethodTerminal {
// The request must be a terminal request if we're using a
// query parameter.
return nil, false
}
err := req.Validate()
if err == nil {
// The request has a valid signed app token, which is a valid