11package coderd_test
22
33import (
4+ "bufio"
45 "context"
56 "encoding/json"
67 "fmt"
@@ -16,6 +17,7 @@ import (
1617 "github.com/google/uuid"
1718 "github.com/stretchr/testify/assert"
1819 "github.com/stretchr/testify/require"
20+ "golang.org/x/xerrors"
1921
2022 "cdr.dev/slog/sloggers/slogtest"
2123 "github.com/coder/coder/agent"
@@ -1024,3 +1026,195 @@ func TestAppSharing(t *testing.T) {
10241026 })
10251027 })
10261028}
1029+
1030+ func TestWorkspaceAppsNonCanonicalHeaders (t * testing.T ) {
1031+ t .Parallel ()
1032+
1033+ setupNonCanonicalHeadersTest := func (t * testing.T , customAppHost ... string ) (* codersdk.Client , codersdk.CreateFirstUserResponse , codersdk.Workspace , uint16 ) {
1034+ // Start a TCP server that manually parses the request. Golang's HTTP
1035+ // server canonicalizes all HTTP request headers it receives, so we
1036+ // can't use it to test that we forward non-canonical headers.
1037+ // #nosec
1038+ ln , err := net .Listen ("tcp" , ":0" )
1039+ require .NoError (t , err )
1040+ go func () {
1041+ for {
1042+ c , err := ln .Accept ()
1043+ if xerrors .Is (err , net .ErrClosed ) {
1044+ return
1045+ }
1046+ require .NoError (t , err )
1047+
1048+ go func () {
1049+ s := bufio .NewScanner (c )
1050+
1051+ // Read request line.
1052+ assert .True (t , s .Scan ())
1053+ reqLine := s .Text ()
1054+ assert .True (t , strings .HasPrefix (reqLine , fmt .Sprintf ("GET /?%s HTTP/1.1" , proxyTestAppQuery )))
1055+
1056+ // Read headers and discard them. We collect the
1057+ // Sec-WebSocket-Key header (with a capital S) to respond
1058+ // with.
1059+ secWebSocketKey := "(none found)"
1060+ for s .Scan () {
1061+ if s .Text () == "" {
1062+ break
1063+ }
1064+
1065+ line := strings .TrimSpace (s .Text ())
1066+ if strings .HasPrefix (line , "Sec-WebSocket-Key: " ) {
1067+ secWebSocketKey = strings .TrimPrefix (line , "Sec-WebSocket-Key: " )
1068+ }
1069+ }
1070+
1071+ // Write response containing text/plain with the
1072+ // Sec-WebSocket-Key header.
1073+ res := fmt .Sprintf ("HTTP/1.1 204 No Content\r \n Sec-WebSocket-Key: %s\r \n Connection: close\r \n \r \n " , secWebSocketKey )
1074+ _ , err = c .Write ([]byte (res ))
1075+ assert .NoError (t , err )
1076+ err = c .Close ()
1077+ assert .NoError (t , err )
1078+ }()
1079+ }
1080+ }()
1081+ t .Cleanup (func () {
1082+ _ = ln .Close ()
1083+ })
1084+ tcpAddr , ok := ln .Addr ().(* net.TCPAddr )
1085+ require .True (t , ok )
1086+
1087+ appHost := proxyTestSubdomainRaw
1088+ if len (customAppHost ) > 0 {
1089+ appHost = customAppHost [0 ]
1090+ }
1091+
1092+ client := coderdtest .New (t , & coderdtest.Options {
1093+ AppHostname : appHost ,
1094+ IncludeProvisionerDaemon : true ,
1095+ AgentStatsRefreshInterval : time .Millisecond * 100 ,
1096+ MetricsCacheRefreshInterval : time .Millisecond * 100 ,
1097+ RealIPConfig : & httpmw.RealIPConfig {
1098+ TrustedOrigins : []* net.IPNet {{
1099+ IP : net .ParseIP ("127.0.0.1" ),
1100+ Mask : net .CIDRMask (8 , 32 ),
1101+ }},
1102+ TrustedHeaders : []string {
1103+ "CF-Connecting-IP" ,
1104+ },
1105+ },
1106+ })
1107+
1108+ user := coderdtest .CreateFirstUser (t , client )
1109+
1110+ workspace := createWorkspaceWithApps (t , client , user .OrganizationID , appHost , uint16 (tcpAddr .Port ))
1111+
1112+ // Configure the HTTP client to not follow redirects and to route all
1113+ // requests regardless of hostname to the coderd test server.
1114+ client .HTTPClient .CheckRedirect = func (req * http.Request , via []* http.Request ) error {
1115+ return http .ErrUseLastResponse
1116+ }
1117+ defaultTransport , ok := http .DefaultTransport .(* http.Transport )
1118+ require .True (t , ok )
1119+ transport := defaultTransport .Clone ()
1120+ transport .DialContext = func (ctx context.Context , network , addr string ) (net.Conn , error ) {
1121+ return (& net.Dialer {}).DialContext (ctx , network , client .URL .Host )
1122+ }
1123+ client .HTTPClient .Transport = transport
1124+ t .Cleanup (func () {
1125+ transport .CloseIdleConnections ()
1126+ })
1127+
1128+ return client , user , workspace , uint16 (tcpAddr .Port )
1129+ }
1130+
1131+ t .Run ("ProxyPath" , func (t * testing.T ) {
1132+ t .Parallel ()
1133+
1134+ client , _ , workspace , _ := setupNonCanonicalHeadersTest (t )
1135+
1136+ ctx , cancel := context .WithTimeout (context .Background (), testutil .WaitLong )
1137+ defer cancel ()
1138+
1139+ u , err := client .URL .Parse (fmt .Sprintf ("/@me/%s/apps/%s/?%s" , workspace .Name , proxyTestAppNameOwner , proxyTestAppQuery ))
1140+ require .NoError (t , err )
1141+
1142+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , u .String (), nil )
1143+ require .NoError (t , err )
1144+
1145+ // Use a non-canonical header name. The S in Sec-WebSocket-Key should be
1146+ // capitalized according to the websocket spec, but Golang will
1147+ // lowercase it to match the HTTP/1 spec.
1148+ //
1149+ // Setting the header on the map directly will force the header to not
1150+ // be canonicalized on the client, but it will be canonicalized on the
1151+ // server.
1152+ secWebSocketKey := "test-dean-was-here"
1153+ req .Header ["Sec-WebSocket-Key" ] = []string {secWebSocketKey }
1154+
1155+ req .Header .Set (codersdk .SessionCustomHeader , client .SessionToken ())
1156+ resp , err := client .HTTPClient .Do (req )
1157+ require .NoError (t , err )
1158+ defer resp .Body .Close ()
1159+
1160+ // The response should be a 204 No Content with the Sec-WebSocket-Key
1161+ // header set to the value we sent.
1162+ res , err := httputil .DumpResponse (resp , true )
1163+ require .NoError (t , err )
1164+ t .Log (string (res ))
1165+ require .Equal (t , http .StatusNoContent , resp .StatusCode )
1166+ require .Equal (t , secWebSocketKey , resp .Header .Get ("Sec-WebSocket-Key" ))
1167+ })
1168+
1169+ t .Run ("Subdomain" , func (t * testing.T ) {
1170+ t .Parallel ()
1171+
1172+ appHost := proxyTestSubdomainRaw
1173+ client , _ , workspace , _ := setupNonCanonicalHeadersTest (t , appHost )
1174+
1175+ ctx , cancel := context .WithTimeout (context .Background (), testutil .WaitLong )
1176+ defer cancel ()
1177+
1178+ user , err := client .User (ctx , codersdk .Me )
1179+ require .NoError (t , err )
1180+
1181+ u := fmt .Sprintf (
1182+ "http://%s--%s--%s--%s%s?%s" ,
1183+ proxyTestAppNameOwner ,
1184+ proxyTestAgentName ,
1185+ workspace .Name ,
1186+ user .Username ,
1187+ strings .ReplaceAll (appHost , "*" , "" ),
1188+ proxyTestAppQuery ,
1189+ )
1190+
1191+ // Re-enable the default redirect behavior.
1192+ client .HTTPClient .CheckRedirect = nil
1193+
1194+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , u , nil )
1195+ require .NoError (t , err )
1196+
1197+ // Use a non-canonical header name. The S in Sec-WebSocket-Key should be
1198+ // capitalized according to the websocket spec, but Golang will
1199+ // lowercase it to match the HTTP/1 spec.
1200+ //
1201+ // Setting the header on the map directly will force the header to not
1202+ // be canonicalized on the client, but it will be canonicalized on the
1203+ // server.
1204+ secWebSocketKey := "test-dean-was-here"
1205+ req .Header ["Sec-WebSocket-Key" ] = []string {secWebSocketKey }
1206+
1207+ req .Header .Set (codersdk .SessionCustomHeader , client .SessionToken ())
1208+ resp , err := client .HTTPClient .Do (req )
1209+ require .NoError (t , err )
1210+ defer resp .Body .Close ()
1211+
1212+ // The response should be a 204 No Content with the Sec-WebSocket-Key
1213+ // header set to the value we sent.
1214+ res , err := httputil .DumpResponse (resp , true )
1215+ require .NoError (t , err )
1216+ t .Log (string (res ))
1217+ require .Equal (t , http .StatusNoContent , resp .StatusCode )
1218+ require .Equal (t , secWebSocketKey , resp .Header .Get ("Sec-WebSocket-Key" ))
1219+ })
1220+ }
0 commit comments