Ctrl K
ComunidadGuías…

coderd/workspaceapps/proxy_internal_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package workspaceapps

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
	"strings"
	"testing"

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"
	"github.com/stretchr/testify/require"

	"cdr.dev/slog/v3"
	"cdr.dev/slog/v3/sloggers/slogjson"
	"github.com/coder/coder/v2/coderd/httpapi"
	"github.com/coder/coder/v2/coderd/tracing"
	"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
	"github.com/coder/coder/v2/codersdk/workspacesdk"
	"github.com/coder/coder/v2/testutil"
	"github.com/coder/quartz"
	"github.com/coder/websocket"
)

// Test_originLocalURL checks that originLocalURL produces a redirect target that
// stays on the current origin.
func Test_originLocalURL(t *testing.T) {
	t.Parallel()

	t.Run("RejectsOffOrigin", func(t *testing.T) {
		t.Parallel()

		// Each path models an already-percent-decoded r.URL.Path that tries to
		// smuggle a separate host into the redirect.
		cases := []struct {
			name string
			path string
		}{
			{name: "DoubleSlash", path: "//evil.com/phish"},
			{name: "TripleSlash", path: "///evil.com/phish"},
			{name: "SlashBackslash", path: "/\\evil.com/phish"},
			{name: "SlashBackslashSlash", path: "/\\/evil.com/phish"},
			{name: "DoubleBackslash", path: "\\\\evil.com/phish"},
			{name: "SlashTab", path: "/\t/evil.com/phish"},
			{name: "SlashTabBackslash", path: "/\t\\evil.com/phish"},
			{name: "SlashNewline", path: "/\n/evil.com/phish"},
			{name: "SlashCarriageReturn", path: "/\r/evil.com/phish"},
			{name: "SlashTabDoubleSlash", path: "/\t//evil.com/phish"},
		}

		for _, tc := range cases {
			t.Run(tc.name, func(t *testing.T) {
				t.Parallel()

				loc := originLocalURL(tc.path).String()

				// The Location must parse as a relative, same-origin reference.
				require.Falsef(t, strings.HasPrefix(loc, "//"),
					"path %q produced scheme-relative Location %q", tc.path, loc)
				parsed, err := url.Parse(loc)
				require.NoErrorf(t, err, "path %q produced unparseable Location %q", tc.path, loc)
				require.Emptyf(t, parsed.Scheme, "path %q produced Location %q with a scheme", tc.path, loc)
				require.Emptyf(t, parsed.Host, "path %q produced Location %q with a host", tc.path, loc)

				// It must also be free of raw bytes a browser would normalize back
				// into an authority before resolving (a backslash becomes "/", and
				// tab/newline/CR are stripped, either of which could re-form
				// "//host"). url.URL.String() guarantees this by percent-encoding
				// them; we assert it here rather than reproducing browser
				// normalization in the code.
				for _, raw := range []string{`\`, "\t", "\n", "\r"} {
					require.NotContainsf(t, loc, raw,
						"path %q produced Location %q containing a raw %q", tc.path, loc, raw)
				}
			})
		}
	})

	t.Run("EscapesControlCharacters", func(t *testing.T) {
		t.Parallel()

		// A redirect built from a path containing a raw control character is an
		// open redirect: http.Redirect emits it verbatim (url.Parse rejects the
		// control byte and skips cleaning) and browsers strip tab/newline/CR
		// before resolving, re-forming "//evil.com". originLocalURL percent-encodes
		// each one. Assert every class is escaped so a future change that breaks
		// encoding for only one class is caught.
		cases := []struct {
			name string
			in   string
			want string
		}{
			{name: "Tab", in: "/\t/evil.com", want: "/%09/evil.com"},
			{name: "Newline", in: "/\n/evil.com", want: "/%0A/evil.com"},
			{name: "CarriageReturn", in: "/\r/evil.com", want: "/%0D/evil.com"},
		}

		for _, tc := range cases {
			t.Run(tc.name, func(t *testing.T) {
				t.Parallel()

				require.Equalf(t, tc.want, originLocalURL(tc.in).String(),
					"originLocalURL(%q) must percent-encode the control character", tc.in)
			})
		}
	})

	t.Run("PreservesLegitPaths", func(t *testing.T) {
		t.Parallel()

		cases := []struct {
			name string
			in   string
			want string
		}{
			{name: "Empty", in: "", want: "/"},
			{name: "Root", in: "/", want: "/"},
			{name: "Simple", in: "/test", want: "/test"},
			{name: "Nested", in: "/app/sub/page", want: "/app/sub/page"},
			{name: "PathApp", in: "/@user/ws/apps/app", want: "/@user/ws/apps/app"},
		}

		for _, tc := range cases {
			t.Run(tc.name, func(t *testing.T) {
				t.Parallel()

				require.Equalf(t, tc.want, originLocalURL(tc.in).String(), "originLocalURL(%q)", tc.in)
			})
		}
	})
}

// fakeTokenProvider returns the same token for every request.
type fakeTokenProvider struct {
	token *SignedToken
}

func (p fakeTokenProvider) FromRequest(*http.Request) (*SignedToken, bool) {
	return p.token, true
}

func (fakeTokenProvider) Issue(context.Context, http.ResponseWriter, *http.Request, IssueTokenRequest) (*SignedToken, string, bool) {
	panic("unexpected Issue call")
}

// unreachableAgentProvider fails every AgentConn with AgentUnreachableError.
type unreachableAgentProvider struct {
	fields []slog.Field
}

func (unreachableAgentProvider) ReverseProxy(*url.URL, *url.URL, uuid.UUID, appurl.ApplicationURL, string) *httputil.ReverseProxy {
	panic("unexpected ReverseProxy call")
}

func (p unreachableAgentProvider) AgentConn(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) {
	return nil, nil, &AgentUnreachableError{Fields: p.fields}
}

func (unreachableAgentProvider) ServeHTTPDebug(http.ResponseWriter, *http.Request) {}

func (unreachableAgentProvider) Close() error { return nil }

func TestWorkspaceAgentPTY_AgentUnreachable(t *testing.T) {
	t.Parallel()
	ctx := testutil.Context(t, testutil.WaitShort)

	logs := testutil.NewWaitBuffer()
	logger := slog.Make(slogjson.Sink(logs)).Leveled(slog.LevelDebug)

	agentID := uuid.New()
	basePath := fmt.Sprintf("/api/v2/workspaceagents/%s/pty", agentID)
	accessURL, err := url.Parse("http://coder.test")
	require.NoError(t, err)

	s := NewServer(ServerOptions{
		Logger:       logger,
		DashboardURL: accessURL,
		AccessURL:    accessURL,
		SignedTokenProvider: fakeTokenProvider{token: &SignedToken{
			Request: Request{
				AccessMethod:  AccessMethodTerminal,
				BasePath:      basePath,
				AgentNameOrID: agentID.String(),
			},
			AgentID: agentID,
		}},
		AgentProvider: unreachableAgentProvider{fields: []slog.Field{
			slog.F("agent_id", agentID),
			slog.F("reason", "no_node"),
		}},
		WSWatcher: httpapi.NewWSWatcher(quartz.NewReal(), nil),
	})
	r := chi.NewRouter()
	r.Use(tracing.StatusWriterMiddleware, tracing.Middleware(nil, tracing.DefaultRoutePatterns, "coderd"))
	r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", s.workspaceAgentPTY)
	srv := httptest.NewServer(r)
	t.Cleanup(srv.Close)

	wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + basePath + "?reconnect=" + uuid.NewString()
	conn, _, err := websocket.Dial(ctx, wsURL, nil) //nolint:bodyclose // The response body is nil after a successful upgrade.
	require.NoError(t, err)
	defer conn.Close(websocket.StatusNormalClosure, "")

	// The handler closes the socket once the dial fails.
	_, _, err = conn.Read(ctx)
	var closeErr websocket.CloseError
	require.ErrorAs(t, err, &closeErr)
	require.Equal(t, websocket.StatusInternalError, closeErr.Code)
	require.Contains(t, closeErr.Reason, "agent is unreachable")

	var found bool
	for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
		var entry struct {
			Level  string         `json:"level"`
			Msg    string         `json:"msg"`
			Fields map[string]any `json:"fields"`
		}
		require.NoError(t, json.Unmarshal([]byte(line), &entry), line)
		if entry.Msg != "agent is unreachable" {
			continue
		}
		found = true
		require.Equal(t, "WARN", entry.Level)
		require.Equal(t, agentID.String(), entry.Fields["agent_id"])
		require.Equal(t, "no_node", entry.Fields["reason"])
	}
	require.True(t, found, "no agent is unreachable log line")
}

func Test_stripQueryParam(t *testing.T) {
	t.Parallel()

	const key = "coder_session_token"
	cases := []struct {
		name        string
		rawQuery    string
		want        string
		wantRemoved bool
	}{
		{name: "Empty", rawQuery: "", want: ""},
		{name: "NoMatch", rawQuery: "b=2&a=1&c=%2C", want: "b=2&a=1&c=%2C"},
		{name: "First", rawQuery: "coder_session_token=K&b=2&a=1", want: "b=2&a=1", wantRemoved: true},
		{name: "Middle", rawQuery: "b=2&coder_session_token=K&a=1", want: "b=2&a=1", wantRemoved: true},
		{name: "Last", rawQuery: "b=2&a=1&coder_session_token=K", want: "b=2&a=1", wantRemoved: true},
		{name: "Only", rawQuery: "coder_session_token=K", want: "", wantRemoved: true},
		{name: "Duplicates", rawQuery: "coder_session_token=K1&b=2&coder_session_token=K2&a=1", want: "b=2&a=1", wantRemoved: true},
		{name: "PercentEncodedKey", rawQuery: "a=1&coder%5Fsession%5Ftoken=K", want: "a=1", wantRemoved: true},
		{name: "ValueWithSemicolon", rawQuery: "coder_session_token=K;&view=1", want: "view=1", wantRemoved: true},
		{name: "KeyWithoutEquals", rawQuery: "a=1&coder_session_token&b=2", want: "a=1&b=2", wantRemoved: true},
		{name: "UnparseableFieldsKept", rawQuery: "filter=a;b&coder_session_token=K&bad=%zz&%zz=1", want: "filter=a;b&bad=%zz&%zz=1", wantRemoved: true},
		{name: "EmptyFieldsKept", rawQuery: "a=1&&b=2&coder_session_token=K&", want: "a=1&&b=2&", wantRemoved: true},
		{name: "SimilarKeysKept", rawQuery: "xcoder_session_token=1&coder_session_token_extra=1", want: "xcoder_session_token=1&coder_session_token_extra=1"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			got, removed := stripQueryParam(tc.rawQuery, key)
			require.Equal(t, tc.want, got)
			require.Equal(t, tc.wantRemoved, removed)
		})
	}
}