Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
108 changes: 103 additions & 5 deletions lib/tidewave.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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

Expand All @@ -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 ] ]
Expand All @@ -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.)
Expand Down Expand Up @@ -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 ] ]
Expand Down Expand Up @@ -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)
Expand All @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion lib/tidewave/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

class Tidewave
class Configuration
attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware
attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team,
:logger_middleware
Comment thread
josevalim marked this conversation as resolved.
Outdated

def initialize
# Rails has a hosts middleware which already checks for this
Expand Down
20 changes: 20 additions & 0 deletions lib/tidewave/magic_bytes.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading