diff --git a/lib/tidewave.rb b/lib/tidewave.rb index 883a47c..95d62b6 100644 --- a/lib/tidewave.rb +++ b/lib/tidewave.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true +require "fileutils" require "ipaddr" require "json" +require "pathname" require "rack/request" +require "uri" require "tidewave/version" require "tidewave/tool" require "tidewave/database_adapter" +require "tidewave/magic_bytes" require "tidewave/railtie" if defined?(Rails::Railtie) class Tidewave @@ -28,7 +32,12 @@ class Tidewave TIDEWAVE_ROUTE = "tidewave".freeze MCP_ROUTE = "mcp".freeze CONFIG_ROUTE = "config".freeze + UPLOAD_ROUTE = "upload".freeze PROTOCOL_VERSION = "2025-03-26".freeze + MAX_UPLOAD_SIZE = 10_000_000 + ALLOWED_UPLOAD_CONTENT_TYPES = [ "image/png", "image/jpeg", "video/webm" ].freeze + ALLOWED_UPLOAD_TYPES = [ "screenshot", "recording" ].freeze + TMP_DIR = "tmp".freeze INVALID_IP = <<~TEXT.freeze For security reasons, Tidewave does not accept remote connections by default. @@ -37,6 +46,7 @@ class Tidewave TEXT INVALID_ORIGIN = "For security reasons, Tidewave does not accept requests with an origin header for this endpoint.".freeze + INVALID_UPLOAD = "Bad Request: missing or invalid file parameter".freeze DEFAULT_OPTIONS = { allow_remote_access: false, @@ -51,6 +61,7 @@ def initialize(app, options = {}) raise ArgumentError, "project_name is required" if @options[:project_name].to_s.empty? @logger = @options[:logger] + @root = @options[:root] ? Pathname.new(@options[:root].to_s) : Pathname.pwd @tools = build_tool_registry end @@ -60,9 +71,8 @@ def call(env) if path[0] == TIDEWAVE_ROUTE return forbidden(INVALID_IP) unless valid_client_ip?(request) - if request.get_header("HTTP_ORIGIN") && !origin_allowed_path?(path) - return forbidden(INVALID_ORIGIN) - end + + return forbidden(INVALID_ORIGIN) if request.get_header("HTTP_ORIGIN") && !origin_allowed_path?(path) case [ request.request_method, path ] when [ "GET", [ TIDEWAVE_ROUTE ] ] @@ -71,6 +81,8 @@ def call(env) config_endpoint(request) when [ "POST", [ TIDEWAVE_ROUTE, MCP_ROUTE ] ] mcp_endpoint(request) + when [ "POST", [ TIDEWAVE_ROUTE, UPLOAD_ROUTE ] ] + upload_endpoint(request) else # The MCP Streamable HTTP transport requires the MCP endpoint to answer # non-POST methods with 405 (GET without SSE support, DELETE, etc.) @@ -154,10 +166,31 @@ def config_data(request) "orm_adapter" => @options[:orm_adapter], "team" => @options[:team] || {}, "tidewave_version" => VERSION, - "local_port" => local_port(request) + "local_port" => local_port(request), + "tmp_dir" => TMP_DIR } end + def upload_endpoint(request) + return text_response(400, INVALID_UPLOAD) if upload_too_large?(request) + + params = request.POST + type = params["type"] + upload = normalize_upload(params["file"]) + + unless ALLOWED_UPLOAD_TYPES.include?(type) && allowed_upload?(upload) + return text_response(400, INVALID_UPLOAD) + end + + FileUtils.mkdir_p(upload_dir(type)) + destination = upload_path(type, upload[:filename]) + FileUtils.cp(upload[:path], destination) + + json_response({ "status" => "ok", "path" => relative_path_from_root(destination) }) + rescue ArgumentError + text_response(400, INVALID_UPLOAD) + end + def json_response(payload, status: 200, headers: {}) body = JSON.generate(payload) [ status, response_headers("application/json", body).merge(headers), [ body ] ] @@ -197,7 +230,11 @@ def response_headers(content_type, body) end def origin_allowed_path?(path) - path == [ TIDEWAVE_ROUTE ] || path == [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] + [ + [ TIDEWAVE_ROUTE ], + [ TIDEWAVE_ROUTE, CONFIG_ROUTE ], + [ TIDEWAVE_ROUTE, UPLOAD_ROUTE ] + ].include?(path) end def local_port(request) @@ -220,6 +257,67 @@ def valid_client_ip?(request) false end + def upload_too_large?(request) + request.content_length && request.content_length.to_i > MAX_UPLOAD_SIZE + end + + def normalize_upload(upload) + case upload + when Hash + tempfile = upload[:tempfile] || upload["tempfile"] + { + filename: upload[:filename] || upload["filename"], + content_type: upload[:type] || upload["type"], + path: tempfile&.path + } + else + return {} unless upload.respond_to?(:original_filename) && upload.respond_to?(:content_type) + + { + filename: upload.original_filename, + content_type: upload.content_type, + path: upload.tempfile&.path + } + end + end + + def allowed_upload?(upload) + ALLOWED_UPLOAD_CONTENT_TYPES.include?(upload[:content_type].to_s.split(";").first) && + upload[:path] && + Tidewave::MagicBytes.type(File.binread(upload[:path], 128)) != :unknown + end + + def upload_dir(type) + @root.join(TMP_DIR, "tidewave", folder_for_upload_type(type)).to_s + end + + def upload_path(type, filename) + filename = filename.to_s + + unless filename.match?(/\A[A-Za-z0-9_.-]+\z/) && !filename.include?("..") + raise ArgumentError, "filename must only contain numbers, letters, hyphens, and underscores: #{filename}" + end + + unless [ ".png", ".jpg", ".jpeg", ".webm" ].include?(File.extname(filename).downcase) + raise ArgumentError, "filename must have a valid extension (.png, .jpg, .jpeg, .webm): #{filename}" + end + + File.join(upload_dir(type), filename) + end + + def folder_for_upload_type(type) + case type + when "screenshot" + "screenshots" + when "recording" + "recordings" + end + end + + def relative_path_from_root(path) + Pathname.new(path).relative_path_from(@root).to_s + end + def validate_jsonrpc_message(message) return "Message must be a JSON object" unless message.is_a?(Hash) return "Invalid JSON-RPC version" unless message["jsonrpc"] == "2.0" diff --git a/lib/tidewave/magic_bytes.rb b/lib/tidewave/magic_bytes.rb new file mode 100644 index 0000000..6325dea --- /dev/null +++ b/lib/tidewave/magic_bytes.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Tidewave + module MagicBytes + module_function + + def type(bytes) + case bytes + when /\A\xFF\xD8\xFF/n + :jpg + when /\A\x89PNG\r\n\x1A\n/n + :png + when /\A\x1A\x45\xDF\xA3/n + bytes.include?("webm") ? :webm : :unknown + else + :unknown + end + end + end +end diff --git a/test/tidewave_test.rb b/test/tidewave_test.rb index d299499..0b6b6f3 100644 --- a/test/tidewave_test.rb +++ b/test/tidewave_test.rb @@ -1,16 +1,27 @@ # frozen_string_literal: true require "test_helper" +require "tmpdir" class TidewaveTest < Minitest::Test def setup + @tmpdir = Dir.mktmpdir("tidewave-test") @downstream_calls = [] @downstream_app = lambda do |env| @downstream_calls << env [ 200, { "content-type" => "text/plain", "x-frame-options" => "DENY" }, [ "demo response" ] ] end - @app = Tidewave.new(@downstream_app, allow_remote_access: true, project_name: "test-app") + @app = Tidewave.new( + @downstream_app, + allow_remote_access: true, + project_name: "test-app", + root: @tmpdir + ) + end + + def teardown + FileUtils.remove_entry(@tmpdir) if @tmpdir && File.directory?(@tmpdir) end def test_non_tidewave_route_passes_through @@ -118,6 +129,7 @@ def test_config_endpoint_returns_json assert_equal({ "id" => "dashbit" }, payload["team"]) assert_equal "demo-app", payload["project_name"] assert_equal 5000, payload["local_port"] + assert_equal "tmp", payload["tmp_dir"] end def test_config_endpoint_includes_orm_adapter_when_configured @@ -200,6 +212,87 @@ def test_root_allows_any_origin assert_equal 200, status end + def test_upload_endpoint_accepts_valid_screenshot_with_origin + status, headers, body = perform_multipart_upload( + @app, + type: "screenshot", + filename: "capture.png", + content_type: "image/png", + content: valid_png, + origin: "http://example.test:3000", + host: "example.test:3000" + ) + + expected_path = File.join(@tmpdir, "tmp", "tidewave", "screenshots", "capture.png") + expected_response_path = File.join("tmp", "tidewave", "screenshots", "capture.png") + + assert_equal 200, status + assert_equal "application/json", headers["content-type"] + assert_equal({ "status" => "ok", "path" => expected_response_path }, JSON.parse(body)) + assert_equal valid_png, File.binread(expected_path) + end + + def test_upload_endpoint_accepts_valid_recording_without_origin + status, _headers, body = perform_multipart_upload( + @app, + type: "recording", + filename: "capture.webm", + content_type: "video/webm;codecs=vp9", + content: valid_webm + ) + + expected_path = File.join(@tmpdir, "tmp", "tidewave", "recordings", "capture.webm") + expected_response_path = File.join("tmp", "tidewave", "recordings", "capture.webm") + + assert_equal 200, status + assert_equal({ "status" => "ok", "path" => expected_response_path }, JSON.parse(body)) + assert_equal valid_webm, File.binread(expected_path) + end + + def test_upload_endpoint_rejects_invalid_type_or_content_type + [ + { type: "other", filename: "capture.png", content_type: "image/png" }, + { type: "screenshot", filename: "capture.txt", content_type: "text/plain" } + ].each do |upload| + status, _headers, body = perform_multipart_upload(@app, **upload, content: valid_png) + + assert_equal 400, status + assert_equal Tidewave::INVALID_UPLOAD, body + end + end + + def test_upload_endpoint_rejects_invalid_file_magic_bytes + status, _headers, body = perform_multipart_upload( + @app, + type: "screenshot", + filename: "capture.png", + content_type: "image/png", + content: "not an image" + ) + + assert_equal 400, status + assert_equal Tidewave::INVALID_UPLOAD, body + end + + def test_upload_endpoint_rejects_invalid_filenames + [ + "capture png.jpg", + "capture.gif", + "..capture.png" + ].each do |filename| + status, _headers, body = perform_multipart_upload( + @app, + type: "screenshot", + filename: filename, + content_type: "image/jpeg", + content: valid_jpg + ) + + assert_equal 400, status + assert_equal Tidewave::INVALID_UPLOAD, body + end + end + def test_no_origin_header_allowed status, headers, body = perform_request(@app, path: "/tidewave/config") @@ -216,24 +309,75 @@ def test_trailing_slash_maps_to_home_route end def test_logs_security_rejections - logger = Minitest::Mock.new - logger.expect(:warn, nil, [ Tidewave::INVALID_IP ]) + warnings = [] + logger = Struct.new(:warnings) do + def warn(message) + warnings << message + end + end.new(warnings) app = Tidewave.new(@downstream_app, allow_remote_access: false, project_name: "test-app", logger: logger) status, _headers, _body = perform_request(app, path: "/tidewave/config", remote_addr: "192.168.1.100") assert_equal 403, status - logger.verify + assert_equal [ Tidewave::INVALID_IP ], warnings end private - def perform_request(app, path:, method: "GET", body: nil, remote_addr: "127.0.0.1", origin: nil, forwarded_for: nil, host: nil, server_port: nil, puma_socket: nil) + def perform_multipart_upload(app, type:, filename:, content_type:, content:, origin: nil, host: "example.test") + body, request_content_type = multipart_body( + type: type, + filename: filename, + content_type: content_type, + content: content + ) + + perform_request( + app, + path: "/tidewave/upload", + method: "POST", + body: body, + content_type: request_content_type, + origin: origin, + host: host + ) + end + + def multipart_body(type:, filename:, content_type:, content:) + boundary = "----tidewave-test-boundary" + body = +"".b + body << "--#{boundary}\r\n" + body << "Content-Disposition: form-data; name=\"type\"\r\n\r\n" + body << "#{type}\r\n" + body << "--#{boundary}\r\n" + body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n" + body << "Content-Type: #{content_type}\r\n\r\n" + body << content + body << "\r\n--#{boundary}--\r\n" + + [ body, "multipart/form-data; boundary=#{boundary}" ] + end + + def valid_jpg + "\xFF\xD8\xFF\xE0JFIF\xFF\xD9".b + end + + def valid_png + "\x89PNG\r\n\x1A\nDATA".b + end + + def valid_webm + "\x1A\x45\xDF\xA3\x42\x82webmDATA".b + end + + def perform_request(app, path:, method: "GET", body: nil, remote_addr: "127.0.0.1", origin: nil, forwarded_for: nil, host: nil, server_port: nil, puma_socket: nil, content_type: nil) env = Rack::MockRequest.env_for(path, method: method, input: body.to_s, "REMOTE_ADDR" => remote_addr) + env["CONTENT_TYPE"] = content_type if content_type env["HTTP_ORIGIN"] = origin if origin env["HTTP_X_FORWARDED_FOR"] = forwarded_for if forwarded_for env["HTTP_HOST"] = host if host