wsv 0.8.0 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3439ce5afffd7bfe4dc3e2a18da2d3f9d15ffd10b28e9cc4bb33f5c3e14ada12
4
- data.tar.gz: 98e65904c83834cc8e910931f1884f52e652346bad94612a8ba31933e607ff8c
3
+ metadata.gz: 027bb497b4d78e1d58abd8aee91bc4349439c29425dc374b535587da67b09997
4
+ data.tar.gz: 5692790c2408ad64761358b3d33efccb439edb0e5c75150beed715bbabfc350e
5
5
  SHA512:
6
- metadata.gz: 382457dc4151007dca51ab8a550021b06bfe4f4127d5ff40c3c9da7e566b3987ec16b57838b8ce5e30a210d538b87231b9f0f0e5e8b5ef91ef20cc5fdc8f9395
7
- data.tar.gz: 2b6210d0c5063c4ba5a8b6c3d723cfe5db3abe9c41b20b6c56a99d64cac0a9b961d694fe2a7b2458250441102ed959034f13f7be169fa0a8102ef46405d8a4f4
6
+ metadata.gz: 2d866a94d0f52f01e7ea50f69a2818bf2c367d704274c362863af11c71647aa80120fa72964bec31f6bfb05356ec401ced291875b9f0bc5c55f9b6b1b4b28fd8
7
+ data.tar.gz: 27cdfaa3126c02c54eeb1268fdec08fc64de82a850e2523865280048f93fbd0d95f5c906eb4da26734a80e36c98570c82ed059d71bbab0459361153a4be1fbb9
data/CHANGELOG.md CHANGED
@@ -1,6 +1,81 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.10.0
4
+
5
+ - Add `--cors` flag. When set, every response carries
6
+ `Access-Control-Allow-Origin: *` and `Vary: Origin`, and `OPTIONS`
7
+ preflight requests get `204 No Content` with `Access-Control-Allow-Methods:
8
+ GET, HEAD, OPTIONS` (echoing `Access-Control-Request-Headers` when
9
+ present). Unblocks browser fetches from a frontend served on a different
10
+ port (or a Service Worker) during local development. Matches the `--cors`
11
+ convention used by `http-server`, `serve`, and `live-server`. Without the
12
+ flag, no CORS headers are added and `OPTIONS` continues to return `405`.
13
+ - Add `--open` flag that launches the OS default browser at the served URL
14
+ on startup. Uses `open` (macOS), `xdg-open` (Linux/BSD), or `cmd /c start`
15
+ (Windows). Best-effort: unsupported platforms or spawn failures are logged
16
+ but never abort the server. Wildcard binds (`0.0.0.0`, `::`) translate to
17
+ the matching loopback address for the URL.
18
+ - Custom 404 page convention: when the served directory contains a
19
+ `404.html` file, it is served as the body of every `404 Not Found`
20
+ response (`Content-Type: text/html`) instead of the built-in plain
21
+ text. Matches Jekyll / Hugo / Netlify behaviour. `403` and other
22
+ error statuses are not rewritten.
23
+ - Add `--spa` flag for single-page-app routing: a request whose path resolves
24
+ to `404` falls back to the root `index.html` so client-side routers (React
25
+ Router, Vue Router, etc.) get the SPA shell instead of a real 404. `403`
26
+ and other errors are unaffected -- dotfile and traversal blocks still
27
+ apply, and existing files at the requested path are served normally.
28
+ - Send `X-Content-Type-Options: nosniff` on every response so browsers
29
+ honour the declared `Content-Type` instead of MIME-sniffing the body.
30
+ - TLS / HTTPS support via Ruby's built-in `openssl` (no extra gem dependency).
31
+ - `--tls` enables HTTPS. Without `--cert / --key`, wsv looks for
32
+ `~/.config/wsv/cert.pem` and `~/.config/wsv/key.pem` (respecting
33
+ `XDG_CONFIG_HOME`); if neither is present, an ephemeral self-signed
34
+ certificate is generated in memory and a warning is printed.
35
+ - `--cert PATH --key PATH` uses a user-supplied PEM cert / key pair and
36
+ implies `--tls`. Specifying only one of the two is an error.
37
+ - `~/.config/wsv/` (or `$XDG_CONFIG_HOME/wsv/`) is the recommended location
38
+ for mkcert-issued certificates: `mkcert -cert-file ~/.config/wsv/cert.pem
39
+ -key-file ~/.config/wsv/key.pem localhost 127.0.0.1 ::1`.
40
+ - The HTTP scheme in the startup banner switches to `https://` when TLS is
41
+ enabled.
42
+ - The TLS handshake honours the per-request read deadline, so slow-handshake
43
+ clients cannot hold a worker beyond the configured timeout.
44
+
45
+ ## 0.9.0
46
+
47
+ - Normalize the redirect `Location` to an origin-form path. Previously, an
48
+ absolute-form request target such as `GET http://example.test/docs HTTP/1.1`
49
+ produced `Location: http://example.test/docs/`; now it always emits
50
+ `Location: /docs/`.
51
+ - Reject control characters (C0 0x00-0x1F and 0x7F DEL) in the
52
+ decoded request path with `400`. RFC 3986 disallows them in URL paths;
53
+ this prevents NUL-byte `ArgumentError` from leaking out of
54
+ `Wsv::PathResolver` and provides defence-in-depth against CR/LF
55
+ smuggling alongside the existing response-header validation.
56
+ - Document the local-FS TOCTOU limitation in README's security model:
57
+ another local process with write access to the served directory can swap
58
+ files between path resolution and `File.open`. This is acknowledged as
59
+ out-of-scope for a development tool.
60
+ - Decrement the in-flight connection counter when `Thread.new` itself raises
61
+ `ThreadError` (e.g. OS thread limit reached). The dispatch returns `503`
62
+ for the rejected client and the server continues accepting subsequent
63
+ connections instead of permanently leaking a slot.
64
+ - Stream file responses through `IO.copy_stream` instead of buffering the
65
+ whole file in memory. Reduces RSS for large files and uses `sendfile(2)`
66
+ on Linux when available. `Response#body` still materializes to a String
67
+ for callers; the change is internal to the wire path.
68
+ - Support `Range` requests for static files (`206 Partial Content` with
69
+ `Content-Range`). Open-ended (`bytes=N-`), suffix (`bytes=-N`), and
70
+ bounded (`bytes=N-M`) forms are supported. Unsatisfiable ranges return
71
+ `416`; invalid syntax falls through to a normal `200`.
72
+ - Honour `If-Modified-Since` and return `304 Not Modified` when the file's
73
+ mtime (truncated to seconds) is at or before the supplied date.
74
+ - Advertise `Accept-Ranges: bytes` on `200` and `206` file responses.
75
+ - Document the public API contract in README: the CLI is the SemVer
76
+ surface. Ruby classes under `lib/wsv/` are implementation details.
77
+
78
+ ## 0.8.0
4
79
 
5
80
  - Bound request size: 8 KiB request line, 8 KiB per header line, 16 KiB total
6
81
  headers, 100 header lines. Returns 414 / 431 when exceeded.
data/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # wsv
2
2
 
3
- `wsv` is a minimal static web server for local previews.
3
+ `wsv` is a zero-dependency static preview server for Ruby projects.
4
4
 
5
5
  It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP.
6
6
 
7
+ Requires Ruby 3.2 or later.
8
+
7
9
  ## Installation
8
10
 
9
11
  ```sh
@@ -14,7 +16,7 @@ For local development:
14
16
 
15
17
  ```sh
16
18
  gem build wsv.gemspec
17
- gem install ./wsv-0.1.0.gem
19
+ gem install ./wsv-*.gem
18
20
  ```
19
21
 
20
22
  ## Usage
@@ -26,8 +28,11 @@ wsv [options] [directory]
26
28
  Examples:
27
29
 
28
30
  ```sh
29
- wsv
30
- wsv public
31
+ wsv # serve current directory
32
+ wsv _site # Jekyll / Bridgetown output
33
+ wsv build # Astro / Hugo output
34
+ wsv --spa dist # Vite / esbuild / webpack SPA output
35
+ wsv --tls --open # HTTPS, open browser at startup
31
36
  wsv -h 0.0.0.0 -p 3000 ./dist
32
37
  ```
33
38
 
@@ -36,18 +41,65 @@ Options:
36
41
  ```text
37
42
  -h, --host HOST Bind host (default: 127.0.0.1)
38
43
  -p, --port PORT Bind port (default: 8000)
44
+ --tls Enable HTTPS (uses ~/.config/wsv/{cert,key}.pem if both present, else self-signed)
45
+ --cert PATH TLS certificate file (PEM); implies --tls
46
+ --key PATH TLS private key file (PEM); implies --tls
47
+ --spa Single-page-app mode: fall back to root index.html on 404
48
+ --open Open the served URL in the default browser at startup
49
+ --cors Send Access-Control-Allow-Origin: * on every response
39
50
  --help Show help
40
51
  --version Show version
41
52
  ```
42
53
 
54
+ ## TLS / HTTPS
55
+
56
+ `--tls` enables HTTPS on the chosen `--port`. Three modes:
57
+
58
+ 1. **Ephemeral self-signed** — `wsv --tls` with no cert configured: wsv
59
+ generates an in-memory self-signed certificate. Browsers will show a
60
+ security warning; click through "Advanced → Proceed" once per session.
61
+ 2. **`~/.config/wsv/` auto-detection (recommended)** — if both
62
+ `~/.config/wsv/cert.pem` and `~/.config/wsv/key.pem` exist (resolved via
63
+ `$XDG_CONFIG_HOME` if set), `--tls` uses them. If only one of the two
64
+ files is present, wsv refuses to start so the misconfiguration does not
65
+ silently fall back to a self-signed certificate. Combine with
66
+ [mkcert](https://github.com/FiloSottile/mkcert) to skip browser warnings:
67
+
68
+ ```sh
69
+ mkcert -install # one-time: register a local CA in your trust stores
70
+ mkdir -p ~/.config/wsv
71
+ mkcert -cert-file ~/.config/wsv/cert.pem \
72
+ -key-file ~/.config/wsv/key.pem \
73
+ localhost 127.0.0.1 ::1
74
+ chmod 600 ~/.config/wsv/key.pem
75
+ wsv --tls # → https://localhost:8000/ with no warning
76
+ ```
77
+
78
+ 3. **Explicit cert/key files** — `wsv --cert path/to/cert.pem --key path/to/key.pem`
79
+ for project-specific certificates. Both flags must be provided together.
80
+
43
81
  ## Behavior
44
82
 
45
83
  - Serves files from the selected directory.
46
84
  - Serves `index.html` for directories that contain it.
47
85
  - Does not render directory listings.
48
86
  - Supports `GET` and `HEAD`.
87
+ - Supports `Range` requests (`206 Partial Content` with `Content-Range`).
88
+ - Honours `If-Modified-Since` and returns `304 Not Modified` when applicable.
49
89
  - Rejects paths that resolve outside the served directory.
50
- - Sends `Cache-Control: no-cache` for local development.
90
+ - Sends `Cache-Control: no-cache` so the browser revalidates each request.
91
+ - With `--spa`, serves the root `index.html` instead of `404` when a path
92
+ resolves to "not found" (so client-side routers like React Router or
93
+ Vue Router work). `403` and other errors are unaffected, so dotfile and
94
+ traversal blocks still apply.
95
+ - If the served directory contains a `404.html` file, it is served as the
96
+ body of every `404 Not Found` response (with `Content-Type: text/html`)
97
+ instead of the built-in plain text. Matches the convention of Jekyll,
98
+ Hugo, and many static hosts.
99
+ - With `--cors`, every response carries `Access-Control-Allow-Origin: *`
100
+ (and `Vary: Origin`), and `OPTIONS` preflight requests get `204 No Content`
101
+ with the matching CORS headers. Lets a frontend on a different port (or a
102
+ Service Worker) fetch assets from `wsv` during local development.
51
103
 
52
104
  ## Security model
53
105
 
@@ -75,23 +127,54 @@ Within that scope it tries to behave defensively:
75
127
  (default 10s, configurable). Stalled connections receive `408`.
76
128
  - Header injection — CR/LF in response header values is rejected at
77
129
  construction time, so user-derived strings cannot inject extra headers.
130
+ - MIME sniffing — every response carries `X-Content-Type-Options: nosniff`
131
+ so browsers honour the declared `Content-Type` rather than guessing from
132
+ body contents.
78
133
  - Single-client monopolisation — connections are handled by a thread pool
79
- capped at `max_connections` (default 8). Excess clients receive `503`.
134
+ capped at `max_connections` (default 8). Excess clients receive `503`
135
+ (or are closed without response in TLS mode, since writing plaintext over
136
+ a half-handshaked TLS socket would corrupt the client's view of the
137
+ protocol).
80
138
  - Transient `accept(2)` errors — per-connection failures (`ECONNABORTED`,
81
139
  `EMFILE`, etc.) are logged and skipped instead of killing the server.
82
140
 
83
141
  ### What `wsv` does NOT do
84
142
 
85
143
  - Authentication, authorization, or rate limiting.
86
- - TLS / HTTPS.
87
- - Range requests, conditional `GET`, or HTTP keep-alive.
144
+ - HTTP keep-alive (each response sets `Connection: close`).
145
+ - HTTP/2. Use Caddy / nginx as a front proxy if you need it.
146
+ - ETags / `If-None-Match`.
88
147
  - Production-grade DoS resistance under hostile network load.
148
+ - Defend against TOCTOU attacks from other local processes that can write
149
+ to the served directory. Path resolution (canonicalisation, dotfile
150
+ checks, within-root verification) happens before each file is opened;
151
+ another process that can swap files in the served directory between
152
+ resolution and read could redirect a request elsewhere on the same
153
+ machine.
89
154
  - Protect a directory you should not be sharing in the first place. The
90
155
  bound is the directory you pass on the command line; if it contains
91
156
  secrets, do not run `wsv` against it.
92
157
 
93
158
  If you need any of the above, use a real production server.
94
159
 
160
+ ## Public API and stability
161
+
162
+ `wsv` follows [Semantic Versioning](https://semver.org/). The public API
163
+ that SemVer covers is the CLI:
164
+
165
+ - The flags listed above (`-h` / `--host`, `-p` / `--port`, `--help`,
166
+ `--version`) and their meanings.
167
+ - The directory argument and the default behaviour when it is omitted.
168
+ - Process exit codes (`0` for success, `1` for usage / setup errors).
169
+
170
+ Within a major version, `wsv` will not silently change the default bind
171
+ host, default port, the dotfile-blocking rule, or the security posture in
172
+ ways that would surprise an existing user.
173
+
174
+ The Ruby classes inside `lib/wsv/` are implementation details. They may
175
+ change in any release, including patches. Pin the gem version if you
176
+ embed `wsv` as a library.
177
+
95
178
  ## License
96
179
 
97
180
  MIT
data/lib/wsv/app.rb CHANGED
@@ -1,38 +1,141 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+ require "uri"
5
+ require_relative "cors"
3
6
  require_relative "path_resolver"
4
7
  require_relative "response"
5
8
 
6
9
  module Wsv
7
10
  class App
8
11
  ALLOWED_METHODS = %w[GET HEAD].freeze
12
+ RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
9
13
 
10
- def initialize(root)
14
+ def initialize(root, spa: false, cors: false)
11
15
  @resolver = PathResolver.new(root)
16
+ @spa = spa
17
+ @cors = Cors.new if cors
12
18
  end
13
19
 
14
20
  def call(request)
21
+ return @cors.preflight(request) if @cors && request.method == "OPTIONS"
22
+
23
+ response = build_response(request)
24
+ @cors ? @cors.overlay(response) : response
25
+ end
26
+
27
+ private
28
+
29
+ def build_response(request)
15
30
  head = request.head?
16
31
 
17
32
  unless ALLOWED_METHODS.include?(request.method)
18
- return Response.text(405, headers: { "Allow" => "GET, HEAD" }, head: head)
33
+ return Response.text(405, headers: { "Allow" => allow_methods }, head: head)
19
34
  end
20
35
 
21
36
  raw_path, query = request.target.split("?", 2)
22
37
  result = @resolver.resolve(raw_path)
23
38
 
24
- return Response.text(result.status, head: head) if result.error?
39
+ # SPA fallback: when the path resolves to 404, retry with "/" so client-side
40
+ # routes (React Router etc.) get index.html instead of a real 404. Other
41
+ # error statuses (403/400) are not rewritten, so dotfile / traversal blocks
42
+ # still take effect.
43
+ if @spa && result.error? && result.status == 404
44
+ fallback = @resolver.resolve("/")
45
+ result = fallback if fallback.file?
46
+ end
47
+
48
+ return error_response(result.status, head: head) if result.error?
25
49
  return Response.redirect(redirect_location(raw_path, query), head: head) if result.redirect?
26
50
 
27
- Response.file(result.file, head: head)
51
+ file_response(result.file, request, head: head)
28
52
  end
29
53
 
30
- private
54
+ def allow_methods
55
+ @cors ? Cors::ALLOW_METHODS : "GET, HEAD"
56
+ end
57
+
58
+ def error_response(status, head:)
59
+ if status == 404
60
+ # Custom 404 page convention: when the served root contains a `404.html`
61
+ # file, serve it as the body of any 404 response (Content-Type: text/html).
62
+ custom = @resolver.resolve("/404.html")
63
+ return Response.file(custom.file, status: 404, head: head) if custom.file?
64
+ end
65
+ Response.text(status, head: head)
66
+ end
67
+
68
+ def file_response(file, request, head:)
69
+ return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
70
+
71
+ size = File.size(file)
72
+ range = parse_range(request.headers["range"], size)
73
+ case range
74
+ when :unsatisfiable
75
+ Response.range_not_satisfiable(size, head: head)
76
+ when nil
77
+ Response.file(file, head: head)
78
+ else
79
+ Response.file(file, head: head, range: range)
80
+ end
81
+ end
82
+
83
+ def not_modified?(file, header_value)
84
+ return false unless header_value
85
+
86
+ since = Time.httpdate(header_value)
87
+ File.mtime(file).to_i <= since.to_i
88
+ rescue ArgumentError
89
+ false
90
+ end
91
+
92
+ def parse_range(header_value, file_size)
93
+ return nil if header_value.nil? || header_value.empty?
94
+
95
+ match = header_value.match(RANGE_PATTERN)
96
+ # Per RFC 7233, an unparseable Range is treated as if absent: fall
97
+ # through as nil so the caller serves a normal 200 instead of 416.
98
+ return nil unless match
99
+
100
+ first, last = match.captures
101
+ if first.nil? && last.nil?
102
+ nil
103
+ elsif first.nil?
104
+ suffix_range(last.to_i, file_size)
105
+ elsif last.nil?
106
+ open_range(first.to_i, file_size)
107
+ else
108
+ bounded_range(first.to_i, last.to_i, file_size)
109
+ end
110
+ end
111
+
112
+ def suffix_range(suffix, file_size)
113
+ return :unsatisfiable if suffix.zero? || file_size.zero?
114
+
115
+ [file_size - suffix, 0].max..(file_size - 1)
116
+ end
117
+
118
+ def open_range(first, file_size)
119
+ return :unsatisfiable if first >= file_size
120
+
121
+ first..(file_size - 1)
122
+ end
123
+
124
+ def bounded_range(first, last, file_size)
125
+ return :unsatisfiable if first > last || first >= file_size
126
+
127
+ last = file_size - 1 if last >= file_size
128
+ first..last
129
+ end
31
130
 
32
131
  def redirect_location(raw_path, query)
33
- location = raw_path.end_with?("/") ? raw_path : "#{raw_path}/"
132
+ path = URI(raw_path.to_s).path
133
+ path = "/" if path.empty?
134
+ location = path.end_with?("/") ? path : "#{path}/"
34
135
  location += "?#{query}" if query && !query.empty?
35
136
  location
137
+ rescue URI::InvalidURIError
138
+ "/"
36
139
  end
37
140
  end
38
141
  end
data/lib/wsv/cli.rb CHANGED
@@ -21,7 +21,13 @@ module Wsv
21
21
  return 0 if options[:handled]
22
22
 
23
23
  root = resolve_root(options[:directory])
24
- server = Server.new(host: options[:host], port: options[:port], root: root, out: @out, err: @err)
24
+ tls = resolve_tls(options)
25
+ server = Server.new(
26
+ host: options[:host], port: options[:port], root: root,
27
+ out: @out, err: @err, tls: tls,
28
+ spa: options[:spa] || false, open: options[:open] || false,
29
+ cors: options[:cors] || false
30
+ )
25
31
  server.start
26
32
  0
27
33
  rescue OptionParser::ParseError, ArgumentError => e
@@ -33,14 +39,14 @@ module Wsv
33
39
  1
34
40
  end
35
41
 
36
- def parse_options(args)
42
+ def parse_options(args) # rubocop:disable Metrics/AbcSize
37
43
  options = {
38
44
  host: DEFAULT_HOST,
39
45
  port: DEFAULT_PORT,
40
46
  directory: Dir.pwd
41
47
  }
42
48
 
43
- parser = OptionParser.new do |opts|
49
+ parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
44
50
  opts.banner = "Usage: wsv [options] [directory]"
45
51
 
46
52
  opts.on("-h", "--host HOST", "Bind host (default: #{DEFAULT_HOST})") do |host|
@@ -51,6 +57,30 @@ module Wsv
51
57
  options[:port] = validate_port(port)
52
58
  end
53
59
 
60
+ opts.on("--tls", "Enable HTTPS (uses ~/.config/wsv/{cert,key}.pem if both present, else self-signed)") do
61
+ options[:tls] = true
62
+ end
63
+
64
+ opts.on("--cert PATH", "TLS certificate file (PEM); implies --tls") do |path|
65
+ options[:cert] = path
66
+ end
67
+
68
+ opts.on("--key PATH", "TLS private key file (PEM); implies --tls") do |path|
69
+ options[:key] = path
70
+ end
71
+
72
+ opts.on("--spa", "Single-page-app mode: fall back to root index.html on 404") do
73
+ options[:spa] = true
74
+ end
75
+
76
+ opts.on("--open", "Open the served URL in the default browser at startup") do
77
+ options[:open] = true
78
+ end
79
+
80
+ opts.on("--cors", "Send Access-Control-Allow-Origin: * on every response") do
81
+ options[:cors] = true
82
+ end
83
+
54
84
  opts.on("--help", "Show help") do
55
85
  @out.puts opts
56
86
  options[:handled] = true
@@ -60,6 +90,14 @@ module Wsv
60
90
  @out.puts Wsv::VERSION
61
91
  options[:handled] = true
62
92
  end
93
+
94
+ opts.separator ""
95
+ opts.separator "Examples:"
96
+ opts.separator " wsv # serve current dir"
97
+ opts.separator " wsv _site # Jekyll / Bridgetown output"
98
+ opts.separator " wsv build # Astro / Hugo output"
99
+ opts.separator " wsv --spa dist # Vite / esbuild / webpack SPA output"
100
+ opts.separator " wsv --tls --open # HTTPS, open browser"
63
101
  end
64
102
 
65
103
  parser.parse!(args)
@@ -84,5 +122,13 @@ module Wsv
84
122
 
85
123
  port
86
124
  end
125
+
126
+ def resolve_tls(options)
127
+ return nil unless options[:tls] || options[:cert] || options[:key]
128
+
129
+ TlsContext::Resolver.resolve(cert_path: options[:cert], key_path: options[:key])
130
+ rescue OpenSSL::OpenSSLError => e
131
+ raise ArgumentError, "TLS configuration error: #{e.message}"
132
+ end
87
133
  end
88
134
  end
data/lib/wsv/cors.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "response"
4
+
5
+ module Wsv
6
+ class Cors
7
+ ALLOW_ORIGIN = "*"
8
+ ALLOW_METHODS = "GET, HEAD, OPTIONS"
9
+ MAX_AGE = "86400"
10
+
11
+ def preflight(request)
12
+ headers = {
13
+ "Access-Control-Allow-Origin" => ALLOW_ORIGIN,
14
+ "Access-Control-Allow-Methods" => ALLOW_METHODS,
15
+ "Access-Control-Max-Age" => MAX_AGE,
16
+ "Vary" => "Origin"
17
+ }
18
+ requested = request.headers["access-control-request-headers"]
19
+ headers["Access-Control-Allow-Headers"] = requested if requested
20
+ Response.new(status: 204, headers: headers)
21
+ end
22
+
23
+ def overlay(response)
24
+ response.with_headers(
25
+ "Access-Control-Allow-Origin" => ALLOW_ORIGIN,
26
+ "Vary" => "Origin"
27
+ )
28
+ end
29
+ end
30
+ end
@@ -4,6 +4,10 @@ require "uri"
4
4
 
5
5
  module Wsv
6
6
  class PathResolver
7
+ # RFC 3986 disallows control characters in URL paths. Reject them after
8
+ # percent-decoding so callers cannot smuggle CR/LF, NUL, etc. through.
9
+ INVALID_PATH_CHARS = /[\u0000-\u001f\u007f]/
10
+
7
11
  class Result
8
12
  attr_reader :status, :file
9
13
 
@@ -46,6 +50,12 @@ module Wsv
46
50
  decoded = decode(raw_path)
47
51
  return Result.error(400) unless decoded
48
52
 
53
+ # Layered defense:
54
+ # 1. URL-level: reject `..` traversal and dotfile segments before
55
+ # touching the filesystem.
56
+ # 2. realpath-level: re-check after symlinks resolve, so an internal
57
+ # symlink cannot smuggle access to outside-root paths or to
58
+ # `.git/` / `.env` etc. via a non-dotfile-looking URL.
49
59
  relative = decoded.sub(%r{\A/+}, "")
50
60
  return Result.error(403) if hidden_segment?(relative)
51
61
 
@@ -77,7 +87,10 @@ module Wsv
77
87
 
78
88
  def decode(raw_path)
79
89
  path = URI(raw_path.to_s).path
80
- percent_decode(path)
90
+ decoded = percent_decode(path)
91
+ return nil if decoded.nil? || decoded.match?(INVALID_PATH_CHARS)
92
+
93
+ decoded
81
94
  rescue URI::InvalidURIError
82
95
  nil
83
96
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Request
5
+ class Parser
6
+ REQUEST_LINE_LIMIT = 8192
7
+ HEADER_LINE_LIMIT = 8192
8
+ HEADER_COUNT_LIMIT = 100
9
+ HEADER_TOTAL_LIMIT = 16_384
10
+
11
+ def initialize(io)
12
+ @io = io
13
+ end
14
+
15
+ def parse
16
+ line = @io.gets("\n", REQUEST_LINE_LIMIT)
17
+ return :empty unless line
18
+ raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
19
+
20
+ method, target, version = line.split(/\s+/, 3)
21
+ version = version&.strip
22
+ return :malformed unless method && target && version&.start_with?("HTTP/")
23
+
24
+ Request.new(method: method, target: target, version: version, headers: read_headers)
25
+ end
26
+
27
+ private
28
+
29
+ def read_headers
30
+ headers = {}
31
+ total = 0
32
+ count = 0
33
+ while (line = @io.gets("\n", HEADER_LINE_LIMIT))
34
+ raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
35
+
36
+ stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
37
+ break if stripped.empty?
38
+
39
+ count += 1
40
+ raise TooLarge, 431 if count > HEADER_COUNT_LIMIT
41
+
42
+ total += line.bytesize
43
+ raise TooLarge, 431 if total > HEADER_TOTAL_LIMIT
44
+
45
+ name, value = stripped.split(":", 2)
46
+ headers[name.downcase] = value.strip if name && value
47
+ end
48
+ headers
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Request
5
+ # Raised by Request::Parser when the incoming request line, an individual
6
+ # header line, the total header bytes, or the header count exceeds the
7
+ # configured limit. The status_code chooses between 414 (URI Too Long)
8
+ # and 431 (Request Header Fields Too Large) at the call site.
9
+ class TooLarge < StandardError
10
+ attr_reader :status_code
11
+
12
+ def initialize(status_code)
13
+ super("request exceeded size limit (#{status_code})")
14
+ @status_code = status_code
15
+ end
16
+ end
17
+ end
18
+ end