wsv 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3439ce5afffd7bfe4dc3e2a18da2d3f9d15ffd10b28e9cc4bb33f5c3e14ada12
4
+ data.tar.gz: 98e65904c83834cc8e910931f1884f52e652346bad94612a8ba31933e607ff8c
5
+ SHA512:
6
+ metadata.gz: 382457dc4151007dca51ab8a550021b06bfe4f4127d5ff40c3c9da7e566b3987ec16b57838b8ce5e30a210d538b87231b9f0f0e5e8b5ef91ef20cc5fdc8f9395
7
+ data.tar.gz: 2b6210d0c5063c4ba5a8b6c3d723cfe5db3abe9c41b20b6c56a99d64cac0a9b961d694fe2a7b2458250441102ed959034f13f7be169fa0a8102ef46405d8a4f4
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Bound request size: 8 KiB request line, 8 KiB per header line, 16 KiB total
6
+ headers, 100 header lines. Returns 414 / 431 when exceeded.
7
+ - Per-request read deadline (default 10s) for slow/idle clients. Returns 408
8
+ on timeout. Configurable via `Server.new(... read_timeout:)`.
9
+ - Reject CR/LF in response header values to prevent header injection.
10
+ - Drain receive buffer before closing to deliver error responses cleanly
11
+ instead of resetting the connection.
12
+ - Minimum Ruby version raised to 3.2 (uses `IO#timeout=`).
13
+ - Apply the dotfile filter to the resolved real path so a symlink inside the
14
+ root cannot smuggle access to `.git/`, `.env`, etc. Symlink loops and
15
+ permission errors now resolve to 404 cleanly.
16
+ - Concurrent connection handling: a thread is spawned per accepted client up
17
+ to `max_connections` (default 8). Idle servers hold no worker threads.
18
+ Connections beyond the cap receive 503 and are closed.
19
+ - Print a `WARNING` to stderr when binding to a non-loopback address so
20
+ exposing the served directory to the network is intentional, not silent.
21
+ - Cap the post-response receive drain at 5 seconds so a malicious or stuck
22
+ client cannot tie up a worker indefinitely while sending body bytes after
23
+ the response has been written.
24
+ - Make the accept loop resilient to transient errors: per-connection failures
25
+ (`ECONNABORTED`, `EMFILE`, `ENOMEM`, etc.) are logged and skipped instead of
26
+ killing the server. A 50 ms backoff prevents tight error loops.
27
+ - Add RuboCop with `rubocop-minitest` / `rubocop-rake` plugins. `rake` now
28
+ runs both `test` and `rubocop`.
29
+ - Add GitHub Actions CI: tests on Ruby 3.2 / 3.3 / 3.4 plus a separate
30
+ RuboCop job.
31
+ - Document the security model in README (what `wsv` protects against and
32
+ what is explicitly out of scope).
33
+
34
+ ## 0.1.0
35
+
36
+ - Initial release.
37
+ - Requests to dotfiles and dot-directories (e.g. `/.git/...`, `/.env`) return 403.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 takahashim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # wsv
2
+
3
+ `wsv` is a minimal static web server for local previews.
4
+
5
+ It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ gem install wsv
11
+ ```
12
+
13
+ For local development:
14
+
15
+ ```sh
16
+ gem build wsv.gemspec
17
+ gem install ./wsv-0.1.0.gem
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```sh
23
+ wsv [options] [directory]
24
+ ```
25
+
26
+ Examples:
27
+
28
+ ```sh
29
+ wsv
30
+ wsv public
31
+ wsv -h 0.0.0.0 -p 3000 ./dist
32
+ ```
33
+
34
+ Options:
35
+
36
+ ```text
37
+ -h, --host HOST Bind host (default: 127.0.0.1)
38
+ -p, --port PORT Bind port (default: 8000)
39
+ --help Show help
40
+ --version Show version
41
+ ```
42
+
43
+ ## Behavior
44
+
45
+ - Serves files from the selected directory.
46
+ - Serves `index.html` for directories that contain it.
47
+ - Does not render directory listings.
48
+ - Supports `GET` and `HEAD`.
49
+ - Rejects paths that resolve outside the served directory.
50
+ - Sends `Cache-Control: no-cache` for local development.
51
+
52
+ ## Security model
53
+
54
+ `wsv` is intended for **local development previews, not for production or internet-facing use**.
55
+ Within that scope it tries to behave defensively:
56
+
57
+ ### What `wsv` protects against
58
+
59
+ - Path traversal — `..`, absolute paths, and URL-encoded forms (`%2e%2e`) are
60
+ resolved and rejected if they escape the served directory.
61
+ - Symlink-based escape — symlinks pointing outside the served directory are
62
+ rejected (403). Symlinks that resolve inside the directory are followed.
63
+ - Symlink-to-dotfile bypass — even if a non-dotfile name is requested, the
64
+ resolved real path is checked again so an internal symlink cannot smuggle
65
+ access to `.git/`, `.env`, etc.
66
+ - Dotfile exposure — any path segment beginning with `.` is rejected (403),
67
+ whether at the URL layer or after symlinks resolve.
68
+ - Unintended LAN exposure — the default bind is `127.0.0.1`. Passing
69
+ `--host 0.0.0.0` (or any non-loopback address) prints a `WARNING` to
70
+ stderr so the choice is explicit.
71
+ - Resource exhaustion from oversized requests — request line, header line,
72
+ total header bytes, and header count are bounded; offending clients receive
73
+ `414` or `431` and are disconnected.
74
+ - Slow / idle clients — each request has a per-request read deadline
75
+ (default 10s, configurable). Stalled connections receive `408`.
76
+ - Header injection — CR/LF in response header values is rejected at
77
+ construction time, so user-derived strings cannot inject extra headers.
78
+ - Single-client monopolisation — connections are handled by a thread pool
79
+ capped at `max_connections` (default 8). Excess clients receive `503`.
80
+ - Transient `accept(2)` errors — per-connection failures (`ECONNABORTED`,
81
+ `EMFILE`, etc.) are logged and skipped instead of killing the server.
82
+
83
+ ### What `wsv` does NOT do
84
+
85
+ - Authentication, authorization, or rate limiting.
86
+ - TLS / HTTPS.
87
+ - Range requests, conditional `GET`, or HTTP keep-alive.
88
+ - Production-grade DoS resistance under hostile network load.
89
+ - Protect a directory you should not be sharing in the first place. The
90
+ bound is the directory you pass on the command line; if it contains
91
+ secrets, do not run `wsv` against it.
92
+
93
+ If you need any of the above, use a real production server.
94
+
95
+ ## License
96
+
97
+ MIT
data/bin/wsv ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "wsv"
5
+
6
+ exit Wsv::CLI.new(ARGV).run
data/lib/wsv/app.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_resolver"
4
+ require_relative "response"
5
+
6
+ module Wsv
7
+ class App
8
+ ALLOWED_METHODS = %w[GET HEAD].freeze
9
+
10
+ def initialize(root)
11
+ @resolver = PathResolver.new(root)
12
+ end
13
+
14
+ def call(request)
15
+ head = request.head?
16
+
17
+ unless ALLOWED_METHODS.include?(request.method)
18
+ return Response.text(405, headers: { "Allow" => "GET, HEAD" }, head: head)
19
+ end
20
+
21
+ raw_path, query = request.target.split("?", 2)
22
+ result = @resolver.resolve(raw_path)
23
+
24
+ return Response.text(result.status, head: head) if result.error?
25
+ return Response.redirect(redirect_location(raw_path, query), head: head) if result.redirect?
26
+
27
+ Response.file(result.file, head: head)
28
+ end
29
+
30
+ private
31
+
32
+ def redirect_location(raw_path, query)
33
+ location = raw_path.end_with?("/") ? raw_path : "#{raw_path}/"
34
+ location += "?#{query}" if query && !query.empty?
35
+ location
36
+ end
37
+ end
38
+ end
data/lib/wsv/cli.rb ADDED
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "pathname"
5
+
6
+ module Wsv
7
+ class CLI
8
+ DEFAULT_HOST = "127.0.0.1"
9
+ DEFAULT_PORT = 8000
10
+
11
+ attr_reader :argv
12
+
13
+ def initialize(argv, out: $stdout, err: $stderr)
14
+ @argv = argv.dup
15
+ @out = out
16
+ @err = err
17
+ end
18
+
19
+ def run
20
+ options = parse_options(argv)
21
+ return 0 if options[:handled]
22
+
23
+ root = resolve_root(options[:directory])
24
+ server = Server.new(host: options[:host], port: options[:port], root: root, out: @out, err: @err)
25
+ server.start
26
+ 0
27
+ rescue OptionParser::ParseError, ArgumentError => e
28
+ @err.puts "wsv: #{e.message}"
29
+ @err.puts "Try `wsv --help` for usage."
30
+ 1
31
+ rescue SystemCallError => e
32
+ @err.puts "wsv: #{e.message}"
33
+ 1
34
+ end
35
+
36
+ def parse_options(args)
37
+ options = {
38
+ host: DEFAULT_HOST,
39
+ port: DEFAULT_PORT,
40
+ directory: Dir.pwd
41
+ }
42
+
43
+ parser = OptionParser.new do |opts|
44
+ opts.banner = "Usage: wsv [options] [directory]"
45
+
46
+ opts.on("-h", "--host HOST", "Bind host (default: #{DEFAULT_HOST})") do |host|
47
+ options[:host] = host
48
+ end
49
+
50
+ opts.on("-p", "--port PORT", Integer, "Bind port (default: #{DEFAULT_PORT})") do |port|
51
+ options[:port] = validate_port(port)
52
+ end
53
+
54
+ opts.on("--help", "Show help") do
55
+ @out.puts opts
56
+ options[:handled] = true
57
+ end
58
+
59
+ opts.on("--version", "Show version") do
60
+ @out.puts Wsv::VERSION
61
+ options[:handled] = true
62
+ end
63
+ end
64
+
65
+ parser.parse!(args)
66
+ raise ArgumentError, "too many directories" if args.length > 1
67
+
68
+ options[:directory] = args.first if args.first
69
+ options
70
+ end
71
+
72
+ private
73
+
74
+ def resolve_root(directory)
75
+ path = Pathname.new(directory).expand_path
76
+ raise ArgumentError, "directory does not exist: #{directory}" unless path.exist?
77
+ raise ArgumentError, "not a directory: #{directory}" unless path.directory?
78
+
79
+ path.realpath.to_s
80
+ end
81
+
82
+ def validate_port(port)
83
+ raise ArgumentError, "port must be between 1 and 65535" unless port.between?(1, 65_535)
84
+
85
+ port
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ module MimeTypes
5
+ DEFAULT = "application/octet-stream"
6
+
7
+ TABLE = {
8
+ ".css" => "text/css; charset=utf-8",
9
+ ".gif" => "image/gif",
10
+ ".html" => "text/html; charset=utf-8",
11
+ ".htm" => "text/html; charset=utf-8",
12
+ ".ico" => "image/x-icon",
13
+ ".jpeg" => "image/jpeg",
14
+ ".jpg" => "image/jpeg",
15
+ ".js" => "text/javascript; charset=utf-8",
16
+ ".json" => "application/json; charset=utf-8",
17
+ ".mjs" => "text/javascript; charset=utf-8",
18
+ ".pdf" => "application/pdf",
19
+ ".png" => "image/png",
20
+ ".svg" => "image/svg+xml; charset=utf-8",
21
+ ".txt" => "text/plain; charset=utf-8",
22
+ ".wasm" => "application/wasm",
23
+ ".webp" => "image/webp",
24
+ ".woff" => "font/woff",
25
+ ".woff2" => "font/woff2"
26
+ }.freeze
27
+
28
+ def self.for_file(file)
29
+ TABLE.fetch(File.extname(file).downcase, DEFAULT)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Wsv
6
+ class PathResolver
7
+ class Result
8
+ attr_reader :status, :file
9
+
10
+ def initialize(kind:, status: nil, file: nil)
11
+ @kind = kind
12
+ @status = status
13
+ @file = file
14
+ end
15
+
16
+ def file?
17
+ @kind == :file
18
+ end
19
+
20
+ def redirect?
21
+ @kind == :redirect
22
+ end
23
+
24
+ def error?
25
+ @kind == :error
26
+ end
27
+
28
+ def self.file(path)
29
+ new(kind: :file, status: 200, file: path)
30
+ end
31
+
32
+ def self.redirect
33
+ new(kind: :redirect, status: 301)
34
+ end
35
+
36
+ def self.error(status)
37
+ new(kind: :error, status: status)
38
+ end
39
+ end
40
+
41
+ def initialize(root)
42
+ @root = root
43
+ end
44
+
45
+ def resolve(raw_path)
46
+ decoded = decode(raw_path)
47
+ return Result.error(400) unless decoded
48
+
49
+ relative = decoded.sub(%r{\A/+}, "")
50
+ return Result.error(403) if hidden_segment?(relative)
51
+
52
+ candidate = File.expand_path(relative, @root)
53
+ return Result.error(403) unless within?(candidate)
54
+ return Result.error(404) unless File.exist?(candidate)
55
+
56
+ real = File.realpath(candidate)
57
+ return Result.error(403) unless within?(real)
58
+ return Result.error(403) if hidden_under_root?(real)
59
+
60
+ if File.directory?(real)
61
+ return Result.redirect unless decoded.end_with?("/")
62
+
63
+ index = File.join(real, "index.html")
64
+ return Result.error(404) unless File.file?(index)
65
+
66
+ return Result.file(index)
67
+ end
68
+
69
+ return Result.error(404) unless File.file?(real)
70
+
71
+ Result.file(real)
72
+ rescue Errno::ENOENT, Errno::ELOOP, Errno::EACCES
73
+ Result.error(404)
74
+ end
75
+
76
+ private
77
+
78
+ def decode(raw_path)
79
+ path = URI(raw_path.to_s).path
80
+ percent_decode(path)
81
+ rescue URI::InvalidURIError
82
+ nil
83
+ end
84
+
85
+ def percent_decode(string)
86
+ decoded = string.gsub(/%([0-9a-fA-F]{2})/) { ::Regexp.last_match(1).hex.chr }
87
+ decoded.force_encoding(Encoding::UTF_8)
88
+ return nil unless decoded.valid_encoding?
89
+
90
+ decoded
91
+ end
92
+
93
+ def hidden_segment?(relative)
94
+ relative.split("/").any? do |segment|
95
+ next false if segment.empty? || segment == "." || segment == ".."
96
+
97
+ segment.start_with?(".")
98
+ end
99
+ end
100
+
101
+ def within?(path)
102
+ path == @root || path.start_with?("#{@root}#{File::SEPARATOR}")
103
+ end
104
+
105
+ def hidden_under_root?(real)
106
+ return false if real == @root
107
+
108
+ hidden_segment?(real[(@root.length + 1)..])
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Request
5
+ REQUEST_LINE_LIMIT = 8192
6
+ HEADER_LINE_LIMIT = 8192
7
+ HEADER_COUNT_LIMIT = 100
8
+ HEADER_TOTAL_LIMIT = 16384
9
+
10
+ class TooLarge < StandardError
11
+ attr_reader :status_code
12
+
13
+ def initialize(status_code)
14
+ super("request exceeded size limit (#{status_code})")
15
+ @status_code = status_code
16
+ end
17
+ end
18
+
19
+ attr_reader :method, :target, :version, :headers
20
+
21
+ def initialize(method:, target:, version:, headers:)
22
+ @method = method
23
+ @target = target
24
+ @version = version
25
+ @headers = headers
26
+ end
27
+
28
+ def head?
29
+ method == "HEAD"
30
+ end
31
+
32
+ def self.parse(io)
33
+ line = io.gets(REQUEST_LINE_LIMIT)
34
+ return :empty unless line
35
+ raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
36
+
37
+ method, target, version = line.split(/\s+/, 3)
38
+ version = version&.strip
39
+ return :malformed unless method && target && version&.start_with?("HTTP/")
40
+
41
+ headers = read_headers(io)
42
+ new(method: method, target: target, version: version, headers: headers)
43
+ end
44
+
45
+ def self.read_headers(io)
46
+ headers = {}
47
+ total = 0
48
+ count = 0
49
+ while (line = io.gets(HEADER_LINE_LIMIT))
50
+ raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
51
+
52
+ stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
53
+ break if stripped.empty?
54
+
55
+ count += 1
56
+ raise TooLarge, 431 if count > HEADER_COUNT_LIMIT
57
+
58
+ total += line.bytesize
59
+ raise TooLarge, 431 if total > HEADER_TOTAL_LIMIT
60
+
61
+ name, value = stripped.split(":", 2)
62
+ headers[name.downcase] = value.strip if name && value
63
+ end
64
+ headers
65
+ end
66
+ private_class_method :read_headers
67
+ end
68
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "mime_types"
5
+ require_relative "status"
6
+ require_relative "version"
7
+
8
+ module Wsv
9
+ class Response
10
+ SERVER_NAME = "wsv/#{Wsv::VERSION}".freeze
11
+
12
+ INVALID_HEADER_NAME = /[\s:]/
13
+ INVALID_HEADER_VALUE = /[\r\n]/
14
+
15
+ attr_reader :status, :headers, :body
16
+
17
+ def initialize(status:, headers: {}, body: "")
18
+ headers.each do |name, value|
19
+ raise ArgumentError, "invalid header name: #{name.inspect}" if name.to_s.match?(INVALID_HEADER_NAME)
20
+ raise ArgumentError, "invalid header value: #{value.inspect}" if value.to_s.match?(INVALID_HEADER_VALUE)
21
+ end
22
+ @status = status
23
+ @headers = headers
24
+ @body = body
25
+ end
26
+
27
+ def reason
28
+ Status.reason(status)
29
+ end
30
+
31
+ def write_to(io)
32
+ io.write "HTTP/1.1 #{status} #{reason}\r\n"
33
+ io.write "Server: #{SERVER_NAME}\r\n"
34
+ io.write "Connection: close\r\n"
35
+ headers.each { |name, value| io.write "#{name}: #{value}\r\n" }
36
+ io.write "\r\n"
37
+ io.write body
38
+ end
39
+
40
+ def self.text(status, headers: {}, head: false)
41
+ body = "#{status} #{Status.reason(status)}\n"
42
+ base = {
43
+ "Content-Type" => "text/plain; charset=utf-8",
44
+ "Content-Length" => body.bytesize.to_s,
45
+ "Cache-Control" => "no-cache"
46
+ }
47
+ new(status: status, headers: base.merge(headers), body: head ? "" : body)
48
+ end
49
+
50
+ def self.file(path, head: false)
51
+ new(
52
+ status: 200,
53
+ headers: {
54
+ "Content-Type" => MimeTypes.for_file(path),
55
+ "Content-Length" => File.size(path).to_s,
56
+ "Last-Modified" => File.mtime(path).httpdate,
57
+ "Cache-Control" => "no-cache"
58
+ },
59
+ body: head ? "" : File.binread(path)
60
+ )
61
+ end
62
+
63
+ def self.redirect(location, head: false)
64
+ text(301, headers: { "Location" => location }, head: head)
65
+ end
66
+ end
67
+ end