Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/client.toit
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class Client:
interface_/tcp.Interface

use-tls-by-default_ ::= false
allow-insecure-redirects_/bool
certificate_/tls.Certificate? ::= null
server-name_/string? ::= null
root-certificates_/List ::= []
Expand Down Expand Up @@ -137,14 +138,23 @@ class Client:
buffer memory for the TLS connection. Call $close (perhaps in a finally
clause) to release the connection.

By default, following an HTTPS-to-HTTP or WSS-to-WS redirect throws
`INSECURE_REDIRECT` before connecting to the insecure destination. This
applies at every hop, including redirects that switch from POST to GET.
Set $allow-insecure-redirects to true to permit these redirects, which may
expose request headers and bodies over plaintext. Explicit HTTP or WS
requests and requests that do not follow redirects are unaffected.

See the `certificate_roots` package for common roots:
https://pkg.toit.io/package/github.com%2Ftoitware%2Ftoit-cert-roots
*/
constructor network/tcp.Interface
--root-certificates/List=[]
--security-store/SecurityStore=SecurityStoreInMemory:
--security-store/SecurityStore=SecurityStoreInMemory
--allow-insecure-redirects/bool=false:
interface_ = network
security-store_ = security-store
allow-insecure-redirects_ = allow-insecure-redirects
root-certificates_ = root-certificates
add-finalizer this:: this.finalize_

Expand All @@ -159,13 +169,17 @@ class Client:
The $server-name can be specified for verifying the TLS certificate. This is
for the rare case where we wish to verify the TLS connections with a
different server name from the one used to establish the connection.

The $allow-insecure-redirects option has the same meaning as in $constructor.
*/
constructor.tls .interface_
--root-certificates/List=[]
--server-name/string?=null
--certificate/tls.Certificate?=null
--security-store/SecurityStore=SecurityStoreInMemory:
--security-store/SecurityStore=SecurityStoreInMemory
--allow-insecure-redirects/bool=false:
security-store_ = security-store
allow-insecure-redirects_ = allow-insecure-redirects
use-tls-by-default_ = true
root-certificates_ = root-certificates
server-name_ = server-name
Expand Down Expand Up @@ -441,7 +455,10 @@ class Client:

get-location_ response/Response previous/ParsedUri_ -> ParsedUri_:
location := response.headers.single "Location"
return ParsedUri_.parse_ location --previous=previous
next := ParsedUri_.parse_ location --previous=previous
if previous.use-tls and not next.use-tls and not allow-insecure-redirects_:
throw "INSECURE_REDIRECT"
return next

redirect-headers_ headers/Headers? previous/ParsedUri_ next/ParsedUri_ -> Headers?:
if not headers or next.can-reuse-connection previous: return headers
Expand Down
103 changes: 103 additions & 0 deletions tests/client-redirect-downgrade-test.toit
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (C) 2026 Toit contributors.
// Use of this source code is governed by a Zero-Clause BSD license that can
// be found in the tests/TESTS_LICENSE file.

import expect show *
import http
import http.connection show Connection

import .memory-socket

OK ::= "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"

main:
[false, true].do: | tls-client/bool |
["GET", "POST", "JSON", "FORM", "WebSocket"].do: | method/string |
[301, 302, 303, 307, 308].do: | status/int |
[false, true].do: | allow/bool |
[false, true].do: | chain/bool |
test-redirect method status --tls-client=tls-client --allow=allow --chain=chain
test-redirect method status --tls-client=tls-client --no-follow
test-explicit-http --tls-client=tls-client
test-secure-redirect

redirect status/int location/string -> string:
return "HTTP/1.1 $status Redirect\r\nLocation: $location\r\nContent-Length: 0\r\n\r\n"

test-redirect method/string status/int
--tls-client/bool --allow/bool=false --chain/bool=false --follow/bool=true:
web-socket := method == "WebSocket"
secure-scheme := web-socket ? "wss" : "https"
insecure-scheme := web-socket ? "ws" : "http"
uri := "$secure-scheme://original.example/"
initial-response := redirect status "$insecure-scheme://other.example/target"
if chain: initial-response = (redirect 307 "/intermediate") + initial-response
// Model an established secure connection. The test exercises redirect
// handling and wire output without certificates or a live TLS server.
first := MemorySocket [initial-response.to-byte-array]
second := MemorySocket [OK.to-byte-array]
network := ScriptedNetwork [second]
client := tls-client ? (http.Client.tls network) : (http.Client network)
if allow:
client.close
client = tls-client
? (http.Client.tls network --allow-insecure-redirects)
: (http.Client network --allow-insecure-redirects)
parsed := http.ParsedUri_.parse_ uri
client.connection_ = Connection first --location=parsed --host=parsed.host-with-port
try:
error := catch:
if web-socket:
client.web-socket --uri=uri --follow-redirects=follow
else:
response := null
if method == "GET":
response = client.get --uri=uri --follow-redirects=follow
else if method == "POST":
response = client.post "synthetic-secret".to-byte-array --uri=uri --follow-redirects=follow
else if method == "JSON":
response = client.post-json {"secret": "synthetic-secret"} --uri=uri --follow-redirects=follow
else:
response = client.post-form {"secret": "synthetic-secret"} --uri=uri --follow-redirects=follow
expect-equals (follow ? 200 : status) response.status-code
if not follow:
expect-equals (web-socket ? "WebSocket upgrade failed with $status Redirect" : null) error
else if not allow:
expect-equals "INSECURE_REDIRECT" error
else:
expect-equals (web-socket ? "WebSocket upgrade failed with 200 OK" : null) error
expect-equals (follow and allow ? 1 : 0) network.index_
sent := second.out.buffer.to-string
if follow and allow:
redirected-method := (method == "GET" or web-socket or status == 303) ? "GET" : "POST"
expect (sent.starts-with "$redirected-method /target HTTP/1.1\r\n")
expect-equals (redirected-method == "POST") (sent.contains "synthetic-secret")
else:
expect-equals "" sent
if chain and follow:
expect (first.out.buffer.to-string.contains " /intermediate HTTP/1.1\r\n")
finally:
client.close

test-explicit-http --tls-client/bool:
socket := MemorySocket [OK.to-byte-array]
network := ScriptedNetwork [socket]
client := tls-client ? (http.Client.tls network) : (http.Client network)
try:
response := client.get --uri="http://original.example/"
expect-equals 200 response.status-code
finally:
client.close

test-secure-redirect:
// A same-origin HTTPS redirect remains allowed with the default policy.
socket := MemorySocket [((redirect 307 "/next") + OK).to-byte-array]
client := http.Client (ScriptedNetwork [])
parsed := http.ParsedUri_.parse_ "https://original.example/"
client.connection_ = Connection socket --location=parsed --host=parsed.host-with-port
try:
response := client.post "synthetic-secret".to-byte-array --uri=parsed.stringify
expect-equals 200 response.status-code
expect (socket.out.buffer.to-string.contains "POST /next HTTP/1.1\r\n")
finally:
client.close
Loading