1
1
package coderd_test
2
2
3
3
import (
4
+ "bufio"
4
5
"context"
5
6
"encoding/json"
6
7
"fmt"
@@ -16,6 +17,7 @@ import (
16
17
"github.com/google/uuid"
17
18
"github.com/stretchr/testify/assert"
18
19
"github.com/stretchr/testify/require"
20
+ "golang.org/x/xerrors"
19
21
20
22
"cdr.dev/slog/sloggers/slogtest"
21
23
"github.com/coder/coder/agent"
@@ -1024,3 +1026,195 @@ func TestAppSharing(t *testing.T) {
1024
1026
})
1025
1027
})
1026
1028
}
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