diff --git a/WebminCore.pm b/WebminCore.pm index 0e7c144cc..030ebb499 100644 --- a/WebminCore.pm +++ b/WebminCore.pm @@ -23,7 +23,7 @@ $main::export_to_caller = 1; # Add functions in web-lib-funcs.pl # Generated with : # grep -h "^sub " web-lib-funcs.pl ui-lib.pl | sed -e 's/sub //' | xargs echo -@EXPORT = qw(read_file read_file_cached read_file_cached_with_stat write_file html_escape html_unescape html_strip quote_escape quote_literal_escape quote_javascript default_webmin_temp_dir tempname_dir tempname_dir_sys webmin_temp_dir_name webmin_temp_dir_path tempname transname transname_timestamped trunc indexof indexoflc sysprint check_ipaddress check_ip6address is_non_public_ipaddress generate_icon urlize un_urlize include copydata ReadParseMime ReadParse read_fully read_parse_mime_callback read_parse_mime_javascript PrintHeader header get_html_title get_html_framed_title get_html_status_line popup_header footer popup_footer load_module_preferences load_theme_library redirect kill_byname kill_byname_logged find_byname error error_stderr popup_error register_error_handler call_error_handlers error_setup wait_for fast_wait_for has_command make_date make_date_relative file_chooser_button popup_window_button popup_window_link read_acl acl_filename acl_check get_miniserv_config_file get_miniserv_config put_miniserv_config restart_miniserv reload_miniserv check_os_support http_download complete_http_download http_post ftp_download ftp_upload no_proxy open_socket download_timeout ftp_command to_ipaddress to_ip6address to_hostname icons_table replace_meta replace_file_line read_file_lines flush_file_lines unflush_file_lines unix_user_input unix_group_input hlink user_chooser_button group_chooser_button foreign_check foreign_exists foreign_available foreign_require foreign_call foreign_config foreign_installed foreign_defined get_system_hostname get_webmin_version get_webmin_version_release get_webmin_full_version get_module_acl get_group_module_acl save_module_acl save_group_module_acl init_config load_language_auto load_language text_subs text encode_base64 decode_base64 encode_base32 decode_base32 get_module_info get_all_module_infos list_themes get_theme_info list_locales list_languages safe_language read_env_file write_env_file lock_file unlock_file test_lock unlock_all_files can_lock_file webmin_log additional_log var_dump webmin_debug_log system_logged backquote_logged backquote_with_timeout backquote_command kill_logged rename_logged rename_file symlink_logged symlink_file link_file make_dir make_dir_recursive set_ownership_permissions unlink_logged unlink_file copy_permissions_source_dest copy_source_dest move_source_dest remote_session_name verify_session_id remote_foreign_require remote_foreign_call remote_foreign_check remote_foreign_config remote_eval remote_write remote_read remote_finished remote_error_setup remote_rpc_call remote_multi_callback remote_multi_callback_error serialise_variable unserialise_variable other_groups date_chooser_button help_file read_help_file seed_random disk_usage_kb recursive_disk_usage help_search_link can_use_http_ssl make_http_connection validate_ssl_connection read_http_connection write_http_connection close_http_connection read_http_headers get_http_auth_reason clean_environment reset_environment clean_language progress_callback switch_to_remote_user switch_to_unix_user eval_as_unix_user create_user_config_dirs create_missing_homedir filter_javascript resolve_links simplify_path same_file flush_webmin_caches list_usermods available_usermods get_available_module_infos get_visible_module_infos get_visible_modules_categories is_under_directory parse_http_url check_clicks_function load_entities_map entities_to_ascii get_product_name get_charset get_display_hostname save_module_config save_user_module_config nice_size get_perl_path get_goto_module select_all_link select_invert_link select_rows_link check_pid_file get_mod_lib module_root_directory list_mime_types guess_mime_type open_tempfile close_tempfile print_tempfile is_selinux_enabled get_clear_file_attributes reset_file_attributes cleanup_tempnames open_lock_tempfile END month_to_number number_to_month supports_ipv6 execute_command execute_command_logged open_readfile open_execute_command translate_filename translate_command register_filename_callback register_command_callback capture_function_output capture_function_output_tempfile modules_chooser_button substitute_template substitute_pattern running_in_zone running_in_vserver running_in_xen running_in_openvz list_categories is_readonly_mode command_as_user list_osdn_mirrors convert_osdn_url get_current_dir supports_users supports_symlinks quote_path get_windows_root read_file_contents write_file_contents read_file_contents_limit unix_crypt split_quoted_string write_to_http_cache check_in_http_cache clear_http_cache supports_javascript get_module_name get_module_variable clear_time_locale reset_time_locale callers_package web_libs_package get_userdb_string connect_userdb disconnect_userdb split_userdb_string uniquelc list_combined_webmin_menu list_modules_webmin_menu module_to_menu_item list_combined_system_info shell_is_bash compare_version_numbers convert_to_json convert_from_json print_json get_referer_relative get_webmin_email_url get_webmin_browser_url trim ui_link ui_help ui_img ui_link_button ui_table_start ui_table_end ui_table_row ui_table_hr ui_table_span ui_columns_start ui_columns_row ui_columns_header ui_checked_columns_row ui_radio_columns_row ui_columns_end ui_columns_table ui_form_columns_table ui_form_elements_wrapper ui_form_start ui_form_end ui_form_end_side_by_side ui_form_grouped_buttons ui_textbox ui_filebox ui_bytesbox ui_upload ui_password ui_hidden ui_select ui_multi_select ui_multi_select_javascript ui_radio ui_yesno_radio ui_radio_row ui_checkbox ui_oneradio ui_textarea ui_user_textbox ui_users_textbox ui_group_textbox ui_groups_textbox ui_opt_textbox ui_submit ui_reset ui_button ui_date_input ui_buttons_start ui_buttons_end ui_buttons_row ui_buttons_hr ui_post_header ui_pre_footer ui_print_header ui_print_unbuffered_header ui_print_footer ui_config_link ui_print_endpage ui_subheading ui_links_row ui_hidden_javascript ui_hidden_start ui_hidden_end ui_hidden_table_row_start ui_hidden_table_row_end ui_hidden_table_start ui_hidden_table_end ui_tabs_start ui_tabs_end ui_tabs_start_tab ui_tabs_start_tabletab ui_tabs_end_tab ui_tabs_end_tabletab ui_max_text_width ui_radio_selector ui_radio_selector_javascript ui_switch_theme_javascript ui_grid_table ui_radio_table ui_up_down_arrows ui_hr ui_nav_link ui_confirmation_form ui_text_color ui_alert_box js_disable_inputs ui_page_flipper ui_form_field_state_javascript js_checkbox_disable js_redirect ui_webmin_link ui_line_break_double ui_page_refresh ui_details ui_div_row ui_space ui_newline ui_text_wrap ui_element_inline ui_paginations ui_hide_outside_of_viewport ui_read_file_contents_limit ui_note ui_brh ui_tag_start ui_tag_content ui_tag_end ui_tag ui_alert ui_button_icon ui_link_icon ui_icon ui_br ui_p ui_div ui_text_mask get_python_cmd get_buffer_size get_buffer_size_binary get_webprefix get_sub_ref_name setvar getvar delvar print_call_stack webmin_user_can_rpc webmin_user_login_mode webmin_user_is_admin webmin_user_is get_current_theme_info_cached miniserv_using_default_cert is_int float is_float parse_accepted_language get_default_system_locale get_http_redirect get_http_cookie create_wrapper get_lock_links_dir allocate_miniserv_websocket get_miniserv_websocket_url remove_miniserv_websocket cleanup_miniserv_websockets get_miniserv_websockets_modules get_webmin_base_url encrypt_phrase decrypt_phrase is_encrypt_phrase); +@EXPORT = qw(read_file read_file_cached read_file_cached_with_stat write_file html_escape html_unescape html_strip quote_escape quote_literal_escape quote_javascript default_webmin_temp_dir tempname_dir tempname_dir_sys webmin_temp_dir_name webmin_temp_dir_path tempname transname transname_timestamped trunc indexof indexoflc sysprint check_ipaddress check_ip6address is_non_public_ipaddress generate_icon urlize un_urlize include copydata ReadParseMime ReadParse read_fully read_parse_mime_callback read_parse_mime_javascript PrintHeader header get_html_title get_html_framed_title get_html_status_line popup_header footer popup_footer load_module_preferences load_theme_library redirect kill_byname kill_byname_logged find_byname error error_stderr popup_error register_error_handler call_error_handlers error_setup wait_for fast_wait_for has_command make_date make_date_relative file_chooser_button popup_window_button popup_window_link read_acl acl_filename acl_check get_miniserv_config_file get_miniserv_config put_miniserv_config restart_miniserv reload_miniserv check_os_support http_download complete_http_download http_post ftp_download ftp_upload no_proxy open_socket download_timeout ftp_command to_ipaddress to_ip6address to_hostname icons_table replace_meta replace_file_line read_file_lines flush_file_lines unflush_file_lines unix_user_input unix_group_input hlink user_chooser_button group_chooser_button foreign_check foreign_exists foreign_available foreign_require foreign_call foreign_config foreign_installed foreign_defined get_system_hostname get_webmin_version get_webmin_version_release get_webmin_full_version get_module_acl get_group_module_acl save_module_acl save_group_module_acl init_config load_language_auto load_language text_subs text encode_base64 decode_base64 encode_base32 decode_base32 get_module_info get_all_module_infos list_themes get_theme_info list_locales list_languages safe_language read_env_file write_env_file lock_file unlock_file test_lock unlock_all_files can_lock_file webmin_log additional_log var_dump webmin_debug_log system_logged backquote_logged backquote_with_timeout backquote_command kill_logged rename_logged rename_file symlink_logged symlink_file link_file make_dir make_dir_recursive set_ownership_permissions unlink_logged unlink_file copy_permissions_source_dest copy_source_dest move_source_dest remote_session_name verify_session_id remote_foreign_require remote_foreign_call remote_foreign_check remote_foreign_config remote_eval remote_write remote_read remote_finished remote_error_setup remote_rpc_call remote_multi_callback remote_multi_callback_error serialise_variable unserialise_variable other_groups date_chooser_button help_file read_help_file seed_random disk_usage_kb recursive_disk_usage help_search_link can_use_http_ssl make_http_connection validate_ssl_connection read_http_connection write_http_connection close_http_connection read_http_headers get_http_auth_reason clean_environment reset_environment clean_language progress_callback switch_to_remote_user switch_to_unix_user eval_as_unix_user create_user_config_dirs create_missing_homedir filter_javascript resolve_links simplify_path same_file flush_webmin_caches list_usermods available_usermods get_available_module_infos get_visible_module_infos get_visible_modules_categories is_under_directory parse_http_url check_clicks_function load_entities_map entities_to_ascii get_product_name get_charset get_display_hostname save_module_config save_user_module_config nice_size get_perl_path get_goto_module select_all_link select_invert_link select_rows_link check_pid_file get_mod_lib module_root_directory list_mime_types guess_mime_type open_tempfile close_tempfile print_tempfile is_selinux_enabled get_clear_file_attributes reset_file_attributes cleanup_tempnames open_lock_tempfile END month_to_number number_to_month supports_ipv6 execute_command execute_command_logged open_readfile open_execute_command translate_filename translate_command register_filename_callback register_command_callback capture_function_output capture_function_output_tempfile modules_chooser_button substitute_template substitute_pattern running_in_zone running_in_vserver running_in_xen running_in_openvz list_categories is_readonly_mode command_as_user list_osdn_mirrors convert_osdn_url get_current_dir supports_users supports_symlinks quote_path get_windows_root read_file_contents write_file_contents read_file_contents_limit unix_crypt split_quoted_string write_to_http_cache check_in_http_cache clear_http_cache supports_javascript get_module_name get_module_variable clear_time_locale reset_time_locale callers_package web_libs_package get_userdb_string connect_userdb disconnect_userdb split_userdb_string uniquelc list_combined_webmin_menu list_modules_webmin_menu module_to_menu_item list_combined_system_info shell_is_bash compare_version_numbers convert_to_json convert_from_json print_json get_referer_relative get_webmin_email_url get_webmin_browser_url trim ui_link ui_help ui_img ui_link_button ui_table_start ui_table_end ui_table_row ui_table_hr ui_table_span ui_columns_start ui_columns_row ui_columns_header ui_checked_columns_row ui_radio_columns_row ui_columns_end ui_columns_table ui_form_columns_table ui_form_elements_wrapper ui_form_start ui_form_end ui_form_end_side_by_side ui_form_grouped_buttons ui_textbox ui_filebox ui_bytesbox ui_upload ui_password ui_hidden ui_select ui_multi_select ui_multi_select_javascript ui_radio ui_yesno_radio ui_radio_row ui_checkbox ui_oneradio ui_textarea ui_user_textbox ui_users_textbox ui_group_textbox ui_groups_textbox ui_opt_textbox ui_submit ui_reset ui_button ui_date_input ui_buttons_start ui_buttons_end ui_buttons_row ui_buttons_hr ui_post_header ui_pre_footer ui_print_header ui_print_unbuffered_header ui_print_footer ui_config_link ui_print_endpage ui_subheading ui_links_row ui_hidden_javascript ui_hidden_start ui_hidden_end ui_hidden_table_row_start ui_hidden_table_row_end ui_hidden_table_start ui_hidden_table_end ui_tabs_start ui_tabs_end ui_tabs_start_tab ui_tabs_start_tabletab ui_tabs_end_tab ui_tabs_end_tabletab ui_max_text_width ui_radio_selector ui_radio_selector_javascript ui_switch_theme_javascript ui_grid_table ui_radio_table ui_up_down_arrows ui_hr ui_nav_link ui_confirmation_form ui_text_color ui_alert_box js_disable_inputs ui_page_flipper ui_form_field_state_javascript js_checkbox_disable js_redirect ui_webmin_link ui_line_break_double ui_page_refresh ui_details ui_div_row ui_space ui_newline ui_text_wrap ui_element_inline ui_paginations ui_hide_outside_of_viewport ui_read_file_contents_limit ui_note ui_brh ui_tag_start ui_tag_content ui_tag_end ui_tag ui_alert ui_button_icon ui_link_icon ui_icon ui_br ui_p ui_div ui_text_mask get_python_cmd get_buffer_size get_buffer_size_binary get_webprefix get_sub_ref_name setvar getvar delvar print_call_stack webmin_user_can_rpc webmin_user_login_mode webmin_user_is_admin webmin_user_is get_current_theme_info_cached miniserv_using_default_cert is_int float is_float parse_accepted_language get_default_system_locale get_http_redirect get_http_cookie create_wrapper get_lock_links_dir generate_miniserv_websocket_token allocate_miniserv_websocket get_miniserv_websocket_url remove_miniserv_websocket cleanup_miniserv_websockets get_miniserv_websockets_modules get_webmin_base_url encrypt_phrase decrypt_phrase is_encrypt_phrase); # Add global variables in web-lib.pl push(@EXPORT, qw(&unique)); diff --git a/miniserv.pl b/miniserv.pl index 7cf6b0aaa..2d0e130d7 100755 --- a/miniserv.pl +++ b/miniserv.pl @@ -1509,6 +1509,17 @@ while(1) { } } alarm(0); +my $websocket_upgrade_request = lc($header{'connection'}) =~ /upgrade/ && + lc($header{'upgrade'}) eq 'websocket'; +my $websocket_configured_request; +if ($websocket_upgrade_request) { + # Check the configured websocket paths before auth, so Basic auth can + # remain disabled for normal session-mode requests. + my $wsbogus; + my $ws_simple = &simplify_path($page, $wsbogus); + $websocket_configured_request = !$wsbogus && + &find_websocket_config($ws_simple); + } # If a remote IP is given in a header (such as via a proxy), only use it # for logging unless trust_real_ip is set @@ -1829,8 +1840,11 @@ if (!$validated && !$deny_authentication) { } } -# Check for normal HTTP authentication -if (!$validated && !$deny_authentication && !$config{'session'} && +# Keep Basic auth disabled in session mode except for configured websocket +# proxy paths. Linked-server websocket hops need it, and token/user checks +# still run before the backend connection is opened. +if (!$validated && !$deny_authentication && + (!$config{'session'} || $websocket_configured_request) && $header{authorization} =~ /^basic\s+(\S+)$/i) { # authorization given.. ($authuser, $authpass) = split(/:/, &b64decode($1), 2); @@ -2335,21 +2349,9 @@ if ($davpath) { } # Check for a websockets request -if (lc($header{'connection'}) =~ /upgrade/ && - lc($header{'upgrade'}) eq 'websocket' && - $baseauthuser) { +if ($websocket_upgrade_request && $baseauthuser) { print DEBUG "websockets request to $simple\n"; - my $ws_simple = $simple; - my ($ws) = grep { $_->{'path'} eq $ws_simple } @websocket_paths; - if (!$ws && $config{'redirect_prefix'}) { - my $prefix = $config{'redirect_prefix'}; - $prefix =~ s/[\/]+$//g; - if ($prefix && $ws_simple =~ s/^\Q$prefix\E(?=\/|$)//) { - $ws_simple ||= "/"; - print DEBUG "websockets retry without prefix $prefix as $ws_simple\n"; - ($ws) = grep { $_->{'path'} eq $ws_simple } @websocket_paths; - } - } + my ($ws, $ws_simple) = &find_websocket_config($simple); if (!$ws) { &http_error(400, "Unknown websocket path"); return 0; @@ -5033,6 +5035,27 @@ foreach my $c (keys %config) { } } +# find_websocket_config(path) +# Returns the websocket config and path, after dropping any query string and +# redirect prefix from the request path. +sub find_websocket_config +{ +my ($simple) = @_; +my $ws_simple = $simple; +$ws_simple =~ s/\?.*$//; +my ($ws) = grep { $_->{'path'} eq $ws_simple } @websocket_paths; +if (!$ws && $config{'redirect_prefix'}) { + my $prefix = $config{'redirect_prefix'}; + $prefix =~ s/[\/]+$//g; + if ($prefix && $ws_simple =~ s/^\Q$prefix\E(?=\/|$)//) { + $ws_simple ||= "/"; + print DEBUG "websockets retry without prefix $prefix as $ws_simple\n"; + ($ws) = grep { $_->{'path'} eq $ws_simple } @websocket_paths; + } + } +return wantarray ? ($ws, $ws_simple) : $ws; +} + # reload_config_file() # Re-read %config, and call post-config actions sub reload_config_file @@ -6081,10 +6104,57 @@ print DEBUG "websockets protos ",join(" ", @protos),"\n"; # Connect to the configured backend my $fh = "WEBSOCKET"; +my ($backend_ssl, $backend_ssl_ctx); if ($ws->{'host'}) { # Backend is a TCP port my $err = &open_socket($ws->{'host'}, $ws->{'port'}, $fh); &http_error(500, "Websockets connection failed : $err") if ($err); + if ($ws->{'ssl'}) { + eval "use Net::SSLeay"; + if ($@) { + &http_error(500, "Missing Net::SSLeay perl module"); + return 0; + } + $backend_ssl_ctx = Net::SSLeay::CTX_new(); + if (!$backend_ssl_ctx) { + &http_error(500, "Failed to create SSL context"); + return 0; + } + $backend_ssl = Net::SSLeay::new($backend_ssl_ctx); + if (!$backend_ssl) { + &http_error(500, "Failed to create SSL connection"); + return 0; + } + Net::SSLeay::set_fd($backend_ssl, fileno($fh)); + my $sslhost = $ws->{'hostheader'} || $ws->{'host'}; + if (defined(&Net::SSLeay::set_tlsext_host_name)) { + # Linked websocket routes may connect to an IP while the + # certificate belongs to the configured linked-server + # host. + my $snihost = $sslhost; + if ($snihost =~ /^\[([^\]]+)\](?::\d+)?$/) { + $snihost = $1; + } + elsif ($snihost =~ /^([^:]+):\d+$/) { + $snihost = $1; + } + Net::SSLeay::set_tlsext_host_name($backend_ssl, $snihost); + } + if (!Net::SSLeay::connect($backend_ssl)) { + &http_error(500, "SSL connect to websockets backend failed"); + return 0; + } + if ($ws->{'checkssl'}) { + my $err = &check_websocket_backend_ssl( + $backend_ssl, $sslhost); + if ($err) { + &http_error(500, + "Invalid SSL certificate from websockets ". + "backend : $err"); + return 0; + } + } + } print DEBUG "websockets host $ws->{'host'}:$ws->{'port'}\n"; } elsif ($ws->{'pipe'}) { @@ -6096,8 +6166,35 @@ elsif ($ws->{'pipe'}) { else { &http_error(500, "Invalid Webmin websockets config"); } +# Keep the rest of the websocket proxy code independent of whether the +# backend hop is plain TCP or wrapped in TLS. +my $backend_write = sub { + my ($buf) = @_; + return $backend_ssl ? Net::SSLeay::write($backend_ssl, $buf) + : syswrite($fh, $buf, length($buf)); + }; +my $backend_read = sub { + my ($size) = @_; + if ($backend_ssl) { + return Net::SSLeay::read($backend_ssl, $size); + } + my $buf; + my $rv = sysread($fh, $buf, $size); + return $rv ? $buf : undef; + }; +my $backend_readline = sub { + my $line = ""; + while(1) { + my $c = $backend_read->(1); + return undef if (!defined($c) || $c eq ""); + $line .= $c; + last if ($c eq "\n"); + } + return $line; + }; -# Send successful connection headers +# Prepare successful connection headers, but don't send them to the browser +# until the backend websocket handshake has succeeded. eval "use Digest::SHA"; if ($@) { &http_error(500, "Missing Digest::SHA perl module"); @@ -6107,40 +6204,46 @@ my $sha1 = Digest::SHA->new; $sha1->add($rkey); my $digest = $sha1->digest; $digest = &b64encode($digest); -&write_data("HTTP/1.1 101 Switching Protocols\r\n"); -&write_data("Upgrade: websocket\r\n"); -&write_data("Connection: Upgrade\r\n"); -&write_data("Sec-Websocket-Accept: $digest\r\n"); -if (@protos) { - &write_data("Sec-Websocket-Protocol: $protos[0]\r\n"); - } -&write_data("\r\n"); # Send a websockets request to the backend my $path = $ws->{'wspath'} || $simple; -my $bsession_id = &b64encode($session_id); +# Normal websocket proxies use the browser session id in the backend +# Sec-WebSocket-Key. Linked xterm routes can instead provide a one-time +# backend session key because the backend request is authenticated with Basic. +my $backend_session = $ws->{'backend_session'} || $session_id; +my $bsession_id = &b64encode($backend_session); print DEBUG "send request to $path to websockets backend\n"; -print $fh "GET $path HTTP/1.1\r\n"; -if ($ws->{'host'}) { - print $fh "Host: $ws->{'host'}\r\n"; +$backend_write->("GET $path HTTP/1.1\r\n"); +if ($ws->{'host'} || $ws->{'hostheader'}) { + $backend_write->("Host: ".($ws->{'hostheader'} || $ws->{'host'})."\r\n"); } -print $fh "Upgrade: websocket\r\n"; -print $fh "Connection: Upgrade\r\n"; +$backend_write->("Upgrade: websocket\r\n"); +$backend_write->("Connection: Upgrade\r\n"); if ($ws->{'nokey'}) { - print $fh "Sec-WebSocket-Key: $key\r\n"; + $backend_write->("Sec-WebSocket-Key: $key\r\n"); } else { print DEBUG "Sending key $bsession_id\n"; - print $fh "Sec-WebSocket-Key: $bsession_id\r\n"; + $backend_write->("Sec-WebSocket-Key: $bsession_id\r\n"); } if (@protos) { - print $fh "Sec-WebSocket-Protocol: ",join(" ", @protos),"\r\n"; + $backend_write->("Sec-WebSocket-Protocol: ".join(" ", @protos)."\r\n"); } -print $fh "Sec-WebSocket-Version: $header{'sec-websocket-version'}\r\n"; -print $fh "\r\n"; +if ($ws->{'origin'}) { + $backend_write->("Origin: $ws->{'origin'}\r\n"); + } +if ($ws->{'auth'} && $ws->{'auth'} =~ /^basic:(\S+)$/) { + $backend_write->("Authorization: Basic $1\r\n"); + } +$backend_write->("Sec-WebSocket-Version: $header{'sec-websocket-version'}\r\n"); +$backend_write->("\r\n"); # Read back the reply -my $rh = <$fh>; +my $rh = $backend_readline->(); +if (!defined($rh)) { + &http_error(500, "No response from websockets backend"); + return 0; + } $rh =~ s/\r|\n//g; print DEBUG "got $rh from websockets backend\n"; $rh =~ /^HTTP\/1\.1\s+(\d+)/ || @@ -6150,7 +6253,11 @@ my $code = $1; my %rheader; my $lastheader; while(1) { - $rh = <$fh>; + $rh = $backend_readline->(); + if (!defined($rh)) { + &http_error(500, "Unexpected EOF from websockets backend"); + return 0; + } $rh =~ s/\r|\n//g; last if ($rh eq ""); if ($rh =~ /^(\S+):\s*(.*)$/) { @@ -6190,6 +6297,16 @@ print DEBUG "expecting digest $bdigest\n"; lc($rheader{'sec-websocket-accept'}) eq lc($bdigest) || &http_error(500, "Incorrect digest header from websockets backend"); +# Send successful connection headers +&write_data("HTTP/1.1 101 Switching Protocols\r\n"); +&write_data("Upgrade: websocket\r\n"); +&write_data("Connection: Upgrade\r\n"); +&write_data("Sec-Websocket-Accept: $digest\r\n"); +if (@protos) { + &write_data("Sec-Websocket-Protocol: $protos[0]\r\n"); + } +&write_data("\r\n"); + # Log now &log_request($loghost, $authuser, $reqline, "101", 0); @@ -6197,6 +6314,10 @@ lc($rheader{'sec-websocket-accept'}) eq lc($bdigest) || seek(DEBUG, 0, 2); print DEBUG "in websockets loop\n"; my $last_session_check_time = time(); +# Frontend browser sockets have a session id and should be revalidated while +# open. Backend hops authenticated without a browser session must not be closed +# by the periodic session check. +my $verify_session = defined($session_id) && $session_id ne ""; while(1) { my $rmask = undef; vec($rmask, fileno($fh), 1) = 1; @@ -6206,8 +6327,8 @@ while(1) { my $uptime = 0; if (vec($rmask, fileno($fh), 1)) { # Got something from the websockets backend - $ok = sysread($fh, $buf, 1024); - last if ($ok <= 0); # Backend has closed + $buf = $backend_read->(1024); + last if (!defined($buf) || length($buf) == 0); &write_data($buf); $uptime = 1; } @@ -6215,11 +6336,11 @@ while(1) { # Got something from the browser $buf = &read_data(1024); last if (!defined($buf) || length($buf) == 0); - syswrite($fh, $buf, length($buf)) || last; + $backend_write->($buf) || last; $uptime = 1; } my $now = time(); - if ($now - $last_session_check_time > 10) { + if ($verify_session && $now - $last_session_check_time > 10) { # Re-validate the browser session every 10 seconds print DEBUG "verifying websockets session $session_id\n"; print $PASSINw "verify $session_id $acptip $uptime\n"; @@ -6231,8 +6352,21 @@ while(1) { $last_session_check_time = $now; } } +Net::SSLeay::free($backend_ssl) if ($backend_ssl); +Net::SSLeay::CTX_free($backend_ssl_ctx) if ($backend_ssl_ctx); close($fh); close(SOCK); +if ($ws->{'path'} =~ /\/ws-link-/) { + # Linked-server websocket routes are single-use routes registered by + # link.cgi, so remove them as soon as the tunnel ends. + &lock_file($config_file); + my %miniserv = &read_config_file($config_file); + if (delete($miniserv{"websockets_$ws->{'path'}"})) { + &write_file($config_file, \%miniserv); + } + &unlock_file($config_file); + &reload_miniserv(); + } print DEBUG "done websockets loop\n"; return 0; @@ -7527,3 +7661,37 @@ foreach my $p (@$hosts) { } return 0; } + +# check_websocket_backend_ssl(ssl-handle, host) +# Returns an error if the websocket backend certificate is not for host. +sub check_websocket_backend_ssl +{ +my ($ssl, $host) = @_; +if ($host =~ /^\[([^\]]+)\](?::\d+)?$/) { + $host = $1; + } +elsif ($host =~ /^([^:]+):\d+$/) { + $host = $1; + } +my $cert = Net::SSLeay::get_peer_certificate($ssl); +return "Could not fetch peer certificate" if (!$cert); +my @hosts; +my $subject = Net::SSLeay::X509_get_subject_name($cert); +if ($subject) { + my $cn = Net::SSLeay::X509_NAME_get_text_by_NID($subject, 13); + push(@hosts, $cn) if (defined($cn) && $cn ne "" && $cn ne "-1"); + } +my @alts = Net::SSLeay::X509_get_subjectAltNames($cert); +while(my ($type, $val) = splice(@alts, 0, 2)) { + push(@hosts, $val) if ($type == 2 || $type == 7); + } +Net::SSLeay::X509_free($cert); +if (&check_ipaddress($host) || &check_ip6address($host)) { + return undef if (grep { lc($_) eq lc($host) } @hosts); + return @hosts ? "Certificate is for ".join(", ", @hosts).", not $host" + : "No certificate names found"; + } +return undef if (@hosts && &ssl_hostname_match($host, \@hosts)); +return @hosts ? "Certificate is for ".join(", ", @hosts).", not $host" + : "No certificate names found"; +} diff --git a/servers/link.cgi b/servers/link.cgi index 13d937324..aed9f5df3 100755 --- a/servers/link.cgi +++ b/servers/link.cgi @@ -191,29 +191,40 @@ else { # read back the rest of the page if ($header->{'content-type'} && - $header->{'content-type'} =~ /text\/html/ && - !$header->{'x-no-links'}) { - # Fix up HTML + $header->{'content-type'} =~ /text\/html/) { + # Fix up HTML. Websocket URLs must always be proxied via a local + # miniserv ws-link route, even when the linked server sets x-no-links + # (as themes do for AJAX page loads), because a websocket connection + # cannot be tunnelled through link.cgi itself. + my $dolinks = !$header->{'x-no-links'}; + &cleanup_link_websockets(); while($_ = &read_http_connection($con)) { - s/src='(\/[^']*)'/src='$url$1'/gi; - s/src="(\/[^"]*)"/src="$url$1"/gi; - s/src=(\/[^ "'>]*)/src=$url$1/gi; - s/href='(\/[^']*)'/href='$url$1'/gi; - s/href="(\/[^"]*)"/href="$url$1"/gi; - s/href=(\/[^ >"']*)/href=$url$1/gi; - s/action='(\/[^']*)'/action='$url$1'/gi; - s/action="(\/[^"]*)"/action="$url$1"/gi; - s/action=(\/[^ "'>]*)/action=$url$1/gi; - s/\.location\s*=\s*'(\/[^']*)'/.location='$url$1'/gi; - s/\.location\s*=\s*"(\/[^']*)"/.location="$url$1"/gi; - s/window.open\("(\/[^"]*)"/window.open\("$url$1"/gi; - s/name=return\s+value="(\/[^"]*)"/name=return value="$url$1"/gi; - s/param\s+name=config\s+value='(\/[^']*)'/param name=config value='$url$1'/gi; - s/param\s+name=config\s+value="(\/[^']*)"/param name=config value="$url$1"/gi; - s/param\s+name=config\s+value=(\/[^']*)/param name=config value=$url$1/gi; + # Websocket URLs can appear in JavaScript strings or JSON values, + # where slashes are escaped. Ordinary HTML rewrites remain gated + # below by x-no-links, but websocket URLs always need local routes. + s#(['"])(wss?://[^'"]+)#"$1".®ister_link_websocket($2, $s, $auth)#egi; + s#(['"])(wss?:\\/\\/[^'"]+)#"$1".®ister_link_websocket($2, $s, $auth)#egi; + if ($dolinks) { + s/src='(\/[^']*)'/src='$url$1'/gi; + s/src="(\/[^"]*)"/src="$url$1"/gi; + s/src=(\/[^ "'>]*)/src=$url$1/gi; + s/href='(\/[^']*)'/href='$url$1'/gi; + s/href="(\/[^"]*)"/href="$url$1"/gi; + s/href=(\/[^ >"']*)/href=$url$1/gi; + s/action='(\/[^']*)'/action='$url$1'/gi; + s/action="(\/[^"]*)"/action="$url$1"/gi; + s/action=(\/[^ "'>]*)/action=$url$1/gi; + s/\.location\s*=\s*'(\/[^']*)'/.location='$url$1'/gi; + s/\.location\s*=\s*"(\/[^']*)"/.location="$url$1"/gi; + s/window.open\("(\/[^"]*)"/window.open\("$url$1"/gi; + s/name=return\s+value="(\/[^"]*)"/name=return value="$url$1"/gi; + s/param\s+name=config\s+value='(\/[^']*)'/param name=config value='$url$1'/gi; + s/param\s+name=config\s+value="(\/[^']*)"/param name=config value="$url$1"/gi; + s/param\s+name=config\s+value=(\/[^']*)/param name=config value=$url$1/gi; + } print; - if (//) { - # Remote webmin file manager applet - give it the + if ($dolinks && //) { + # Remote webmin file manager applet - give it the # session ID on *this* system print "\n"; } @@ -237,3 +248,87 @@ else { } &close_http_connection($con); +# register_link_websocket(url, &server, auth) +# Registers a local miniserv websocket proxy for a websocket URL generated by +# the linked Webmin server, and returns the local URL for the browser to use. +sub register_link_websocket +{ +my ($wsurl, $s, $auth) = @_; +# JSON script responses can encode wss:// as wss:\/\/. Normalize before +# matching, and restore the escaping style for the returned URL below. +my $escaped_slashes = $wsurl =~ /\\\//; +$wsurl =~ s#\\/#/#g; +my $host = $s->{'host'}; +my $port = $s->{'port'}; +my $ssl = $s->{'ssl'}; +my $proto = $ssl ? "wss" : "ws"; +my ($url_proto, $url_host, $url_port, $remote_path) = + $wsurl =~ /^(wss?):\/\/(\[[^\]]+\]|[^:\/]+)(?::(\d+))?(\/[^'"]*)$/i; +return $wsurl if (!$remote_path || lc($url_proto) ne $proto); +return $wsurl if ($url_host =~ /[\s\x00-\x1f\x7f]/ || + $remote_path =~ /[\s\x00-\x1f\x7f]/); +my $url_host_cmp = lc($url_host); +$url_host_cmp =~ s/^\[|\]$//g; +my @valid_hosts = grep { defined($_) && $_ ne "" } ($host, $s->{'ip'}); +my $link_prefix = &get_webprefix()."/$module_name/link.cgi/$s->{'id'}"; +my $from_link_path = $remote_path =~ s/^\Q$link_prefix\E//; +# Only websocket URLs owned by the linked server are proxied. Some themes +# build them with the link.cgi prefix already present; strip that prefix +# before registering the backend path. +return $wsurl if (!$from_link_path && + !grep { lc($_) eq $url_host_cmp } @valid_hosts); +my ($remote_port) = $remote_path =~ /\/ws-(\d+)(?:\?|$)/; +return $wsurl if (!$remote_port); + +my $token = &generate_miniserv_websocket_token(); +my $wspath = "/$module_name/ws-link-$s->{'id'}-$remote_port"; +my $now = time(); +my $backend_host = $s->{'ip'} || $host; +my $defport = $ssl ? 443 : 80; +my $hostheader = $url_host; +$hostheader .= ":".(defined($url_port) ? $url_port : $port) + if ((defined($url_port) ? $url_port : $port) != $defport); +my $origin = ($ssl ? "https" : "http")."://".$hostheader; +my $checkssl = $s->{'checkssl'} ? 1 : 0; +my %miniserv; +&lock_file(&get_miniserv_config_file()); +&get_miniserv_config(\%miniserv); +$miniserv{"websockets_$wspath"} = + "host=$backend_host port=$port ssl=$ssl wspath=$remote_path ". + "hostheader=$hostheader origin=$origin auth=basic:$auth ". + "checkssl=$checkssl nokey=1 user=$main::remote_user ". + "token=$token time=$now"; +&put_miniserv_config(\%miniserv); +&unlock_file(&get_miniserv_config_file()); +&reload_miniserv(); + +# Pass the fresh token directly. Re-reading miniserv.conf here can race the +# config cache and return a stale token for the same ws-link path. +my $rv = &get_miniserv_websocket_url( + undef, undef, $module_name, $wspath, $token); +$rv =~ s#/#\\/#g if ($escaped_slashes); +return $rv; +} + +# cleanup_link_websockets() +# Removes abandoned websocket proxy routes created for linked Webmin servers. +# Active routes are removed by miniserv when their websocket tunnel closes. +sub cleanup_link_websockets +{ +my %miniserv; +my $now = time(); +my $changed = 0; +&lock_file(&get_miniserv_config_file()); +&get_miniserv_config(\%miniserv); +foreach my $k (keys %miniserv) { + next if ($k !~ /^websockets_\/\Q$module_name\E\/ws-link-/); + my ($time) = $miniserv{$k} =~ /\btime=(\d+)/; + if (!$time || $now - $time > 24*60*60) { + delete($miniserv{$k}); + $changed++; + } + } +&put_miniserv_config(\%miniserv) if ($changed); +&unlock_file(&get_miniserv_config_file()); +&reload_miniserv() if ($changed); +} diff --git a/t/miniserv.t b/t/miniserv.t index c9335a153..f65ee2f97 100644 --- a/t/miniserv.t +++ b/t/miniserv.t @@ -1287,7 +1287,7 @@ EOF subtest 'parse_websockets_config' => sub { no warnings 'once'; local %miniserv::config = ( - 'websockets_/chat' => 'host=back.example.com port=9000 proto=ws', + 'websockets_/chat' => 'host=back.example.com port=9000 proto=ws ssl=1 checkssl=1 backend_session=abc123', 'unrelated_key' => 'ignored', ); local @miniserv::websocket_paths = (); @@ -1299,6 +1299,26 @@ subtest 'parse_websockets_config' => sub { is($ws->{host}, 'back.example.com', 'host kv parsed'); is($ws->{port}, '9000', 'port kv parsed'); is($ws->{proto}, 'ws', 'proto kv parsed'); + is($ws->{ssl}, '1', 'ssl kv parsed'); + is($ws->{checkssl}, '1', 'SSL check kv parsed'); + is($ws->{backend_session}, 'abc123', 'backend session kv parsed'); +}; + +# find_websocket_config +subtest 'find_websocket_config' => sub { + no warnings 'once'; + local %miniserv::config = ( 'redirect_prefix' => '/prefix' ); + local @miniserv::websocket_paths = ( + { 'path' => '/chat' }, + ); + + my ($ws, $simple) = miniserv::find_websocket_config('/prefix/chat'); + is($ws->{path}, '/chat', 'matches after redirect prefix stripping'); + is($simple, '/chat', 'returns canonical websocket path'); + ($ws, $simple) = miniserv::find_websocket_config('/prefix/chat?token=abc'); + is($ws->{path}, '/chat', 'matches request path with query string'); + is($simple, '/chat', 'drops query string from canonical path'); + ok(!miniserv::find_websocket_config('/missing'), 'unknown path rejected'); }; # get_user_details — local-files branch (skips the userdb code path entirely) diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index 0e9b1fbcc..6c5a10eb9 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -14294,11 +14294,11 @@ if (!$token) { return $token; } -# allocate_miniserv_websocket([module], [base-remote-user]) +# allocate_miniserv_websocket([module], [base-remote-user], [backend-session]) # Allocate a new websocket and stores it miniserv.conf file sub allocate_miniserv_websocket { -my ($module, $buser) = @_; +my ($module, $buser, $backend_session) = @_; $module ||= $module_name; # Find ports already in use &lock_file(&get_miniserv_config_file()); @@ -14326,40 +14326,65 @@ my $now = time(); my $token = &generate_miniserv_websocket_token(); my $opt_buser = ""; $opt_buser = " buser=$buser" if (defined($buser) && $buser eq $base_remote_user); +my $opt_backend = ""; +# Some websocket backends are reached with Basic auth and no browser session +# cookie. Store the one-time backend session key that the child server expects. +$opt_backend = " backend_session=$backend_session" + if (defined($backend_session) && $backend_session =~ /^\S+$/); $miniserv{"websockets_$wspath"} = "host=127.0.0.1 port=$port wspath=/ ". - "user=$remote_user$opt_buser token=$token time=$now"; + "user=$remote_user$opt_buser$opt_backend token=$token time=$now"; &put_miniserv_config(\%miniserv); &unlock_file(&get_miniserv_config_file()); &reload_miniserv(); return $port; } -# get_miniserv_websocket_url(port, [host], [module]) -# Returns the URL for a websocket +# get_miniserv_websocket_url(port, [host], [module], [path], [token]) +# Returns the browser-visible URL for a websocket. The optional path/token +# arguments are used by linked-server websocket proxy routes, whose path does +# not follow the normal /module/ws-port form. sub get_miniserv_websocket_url { -my ($port, $host, $module) = @_; +my ($port, $host, $module, $path, $wstoken) = @_; $module ||= $module_name; -my $ws_proto = lc($ENV{'HTTPS'}) eq 'on' ? 'wss' : 'ws'; my %miniserv; my $webprefix = &get_webprefix(); &get_miniserv_config(\%miniserv); my $trust_proxy = $miniserv{'trust_real_ip'}; -my $wspath = "/$module/ws-".$port; -my $wstoken; -if ($miniserv{'websockets_'.$wspath} && +my $default_ws_proto = lc($ENV{'HTTPS'}) eq 'on' ? 'wss' : 'ws'; +my $ws_proto; +# Match the public browser scheme when Webmin is behind a trusted reverse +# proxy, but only allow websocket schemes into the returned URL. +if ($trust_proxy && $ENV{'HTTP_X_FORWARDED_PROTO'}) { + $ws_proto = (split(/\s*,\s*/, $ENV{'HTTP_X_FORWARDED_PROTO'}))[0]; + $ws_proto =~ s/^\s+|\s+$//g; + } +if (!$ws_proto && $trust_proxy && $ENV{'HTTP_FORWARDED'} && + (split(/\s*,\s*/, $ENV{'HTTP_FORWARDED'}))[0] =~ + /(?:^|;)\s*proto="?([^";]+)"?/i) { + $ws_proto = $1; + } +$ws_proto ||= $default_ws_proto; +$ws_proto = lc($ws_proto); +$ws_proto = 'wss' if ($ws_proto eq 'https'); +$ws_proto = 'ws' if ($ws_proto eq 'http'); +$ws_proto = $default_ws_proto if ($ws_proto ne 'wss' && $ws_proto ne 'ws'); +my $wspath = $path || "/$module/ws-".$port; +# If the caller already generated the token, use it directly; otherwise fall +# back to reading the token stored for the normal allocated websocket route. +if (!defined($wstoken) && $miniserv{'websockets_'.$wspath} && $miniserv{'websockets_'.$wspath} =~ /\btoken=(\S+)/) { $wstoken = $1; } my $http_host_conf = &trim($miniserv{'websocket_host'} || $host); -# Pass as defined +# Prefer the explicit websocket host when configured if ($http_host_conf) { if ($http_host_conf !~ /^wss?:\/\//) { $http_host_conf = "$ws_proto://$http_host_conf"; } $http_host_conf =~ s/[\/]+$//g; } -# Try to rely on the proxy +# Otherwise use trusted proxy headers when available if ($trust_proxy && !defined($http_host_conf)) { my $forwarded_host = $ENV{'HTTP_X_FORWARDED_HOST'}; if ($forwarded_host) { diff --git a/xterm/index.cgi b/xterm/index.cgi index a9c6157e7..1a7818021 100755 --- a/xterm/index.cgi +++ b/xterm/index.cgi @@ -11,8 +11,6 @@ our (%in, %text, %config, %gconfig, %access, %module_info, $remote_user); ReadParse(); -$ENV{'HTTP_WEBMIN_PATH'} && error($text{'index_eproxy'}); - # Check for needed modules my @modload = ( ['Digest::SHA', sub { eval { require Digest::SHA; Digest::SHA->import; 1 } }], @@ -181,8 +179,12 @@ ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0, undef, # Print main container print "
\n"; -# Get a free port that can be used for the socket -my $port = allocate_miniserv_websocket($module_name); +# Get a free port that can be used for the socket. Proxied or Basic-auth +# requests may not have a Webmin session cookie, so give the backend its own +# one-time handshake secret. +my $websocket_session_id = $main::session_id || generate_miniserv_websocket_token(); +my $port = allocate_miniserv_websocket( + $module_name, undef, $websocket_session_id); # Decide which Unix account the terminal will run as my $user = resolve_shell_user(\%access, $remote_user, \%in, \%config); @@ -197,7 +199,9 @@ my $shellserver_cmd = "$module_config_directory/shellserver.pl"; if (!-r $shellserver_cmd) { create_wrapper($shellserver_cmd, $module_name, "shellserver.pl"); } -$ENV{'SESSION_ID'} = $main::session_id; +# shellserver.pl validates the websocket key against SESSION_ID; keep it in +# sync with the backend_session stored in miniserv.conf above. +$ENV{'SESSION_ID'} = $websocket_session_id; system_logged($shellserver_cmd." ".quotemeta($port)." ".quotemeta($user). ($dir ? " ".quotemeta($dir) : ""). " >$module_var_directory/websocket-connection-$port.out 2>&1