wsv 0.9.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 +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +65 -11
- data/lib/wsv/app.rb +39 -4
- data/lib/wsv/cli.rb +49 -3
- data/lib/wsv/cors.rb +30 -0
- data/lib/wsv/path_resolver.rb +6 -0
- data/lib/wsv/request/parser.rb +2 -2
- data/lib/wsv/request/too_large.rb +18 -0
- data/lib/wsv/request.rb +1 -9
- data/lib/wsv/response/file_builder.rb +3 -2
- data/lib/wsv/response.rb +9 -0
- data/lib/wsv/server/banner.rb +56 -0
- data/lib/wsv/server/browser_launcher.rb +63 -0
- data/lib/wsv/server/deadline_reader.rb +23 -0
- data/lib/wsv/server.rb +62 -36
- data/lib/wsv/status.rb +1 -0
- data/lib/wsv/tls_context/resolver.rb +71 -0
- data/lib/wsv/tls_context/self_signed_cert.rb +38 -0
- data/lib/wsv/tls_context.rb +29 -0
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +2 -0
- data/test/app_test.rb +194 -0
- data/test/browser_launcher_test.rb +57 -0
- data/test/cli_test.rb +64 -0
- data/test/response_test.rb +8 -0
- data/test/server_test.rb +57 -2
- data/test/tls_context_test.rb +169 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 027bb497b4d78e1d58abd8aee91bc4349439c29425dc374b535587da67b09997
|
|
4
|
+
data.tar.gz: 5692790c2408ad64761358b3d33efccb439edb0e5c75150beed715bbabfc350e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d866a94d0f52f01e7ea50f69a2818bf2c367d704274c362863af11c71647aa80120fa72964bec31f6bfb05356ec401ced291875b9f0bc5c55f9b6b1b4b28fd8
|
|
7
|
+
data.tar.gz: 27cdfaa3126c02c54eeb1268fdec08fc64de82a850e2523865280048f93fbd0d95f5c906eb4da26734a80e36c98570c82ed059d71bbab0459361153a4be1fbb9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
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
|
+
|
|
3
45
|
## 0.9.0
|
|
4
46
|
|
|
5
47
|
- Normalize the redirect `Location` to an origin-form path. Previously, an
|
data/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# wsv
|
|
2
2
|
|
|
3
|
-
`wsv` is a
|
|
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
|
|
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
|
|
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,10 +41,43 @@ 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.
|
|
@@ -50,6 +88,18 @@ Options:
|
|
|
50
88
|
- Honours `If-Modified-Since` and returns `304 Not Modified` when applicable.
|
|
51
89
|
- Rejects paths that resolve outside the served directory.
|
|
52
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.
|
|
53
103
|
|
|
54
104
|
## Security model
|
|
55
105
|
|
|
@@ -77,16 +127,22 @@ Within that scope it tries to behave defensively:
|
|
|
77
127
|
(default 10s, configurable). Stalled connections receive `408`.
|
|
78
128
|
- Header injection — CR/LF in response header values is rejected at
|
|
79
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.
|
|
80
133
|
- Single-client monopolisation — connections are handled by a thread pool
|
|
81
|
-
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).
|
|
82
138
|
- Transient `accept(2)` errors — per-connection failures (`ECONNABORTED`,
|
|
83
139
|
`EMFILE`, etc.) are logged and skipped instead of killing the server.
|
|
84
140
|
|
|
85
141
|
### What `wsv` does NOT do
|
|
86
142
|
|
|
87
143
|
- Authentication, authorization, or rate limiting.
|
|
88
|
-
- TLS / HTTPS.
|
|
89
144
|
- HTTP keep-alive (each response sets `Connection: close`).
|
|
145
|
+
- HTTP/2. Use Caddy / nginx as a front proxy if you need it.
|
|
90
146
|
- ETags / `If-None-Match`.
|
|
91
147
|
- Production-grade DoS resistance under hostile network load.
|
|
92
148
|
- Defend against TOCTOU attacks from other local processes that can write
|
|
@@ -115,11 +171,9 @@ Within a major version, `wsv` will not silently change the default bind
|
|
|
115
171
|
host, default port, the dotfile-blocking rule, or the security posture in
|
|
116
172
|
ways that would surprise an existing user.
|
|
117
173
|
|
|
118
|
-
The Ruby classes inside `lib/wsv/`
|
|
119
|
-
|
|
120
|
-
`
|
|
121
|
-
time, including in patch releases. If you want to embed `wsv` as a
|
|
122
|
-
library, pin a specific version.
|
|
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.
|
|
123
177
|
|
|
124
178
|
## License
|
|
125
179
|
|
data/lib/wsv/app.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
require "uri"
|
|
5
|
+
require_relative "cors"
|
|
5
6
|
require_relative "path_resolver"
|
|
6
7
|
require_relative "response"
|
|
7
8
|
|
|
@@ -10,27 +11,59 @@ module Wsv
|
|
|
10
11
|
ALLOWED_METHODS = %w[GET HEAD].freeze
|
|
11
12
|
RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
|
|
12
13
|
|
|
13
|
-
def initialize(root)
|
|
14
|
+
def initialize(root, spa: false, cors: false)
|
|
14
15
|
@resolver = PathResolver.new(root)
|
|
16
|
+
@spa = spa
|
|
17
|
+
@cors = Cors.new if cors
|
|
15
18
|
end
|
|
16
19
|
|
|
17
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)
|
|
18
30
|
head = request.head?
|
|
19
31
|
|
|
20
32
|
unless ALLOWED_METHODS.include?(request.method)
|
|
21
|
-
return Response.text(405, headers: { "Allow" =>
|
|
33
|
+
return Response.text(405, headers: { "Allow" => allow_methods }, head: head)
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
raw_path, query = request.target.split("?", 2)
|
|
25
37
|
result = @resolver.resolve(raw_path)
|
|
26
38
|
|
|
27
|
-
|
|
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?
|
|
28
49
|
return Response.redirect(redirect_location(raw_path, query), head: head) if result.redirect?
|
|
29
50
|
|
|
30
51
|
file_response(result.file, request, head: head)
|
|
31
52
|
end
|
|
32
53
|
|
|
33
|
-
|
|
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
|
|
34
67
|
|
|
35
68
|
def file_response(file, request, head:)
|
|
36
69
|
return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
|
|
@@ -60,6 +93,8 @@ module Wsv
|
|
|
60
93
|
return nil if header_value.nil? || header_value.empty?
|
|
61
94
|
|
|
62
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.
|
|
63
98
|
return nil unless match
|
|
64
99
|
|
|
65
100
|
first, last = match.captures
|
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
|
-
|
|
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
|
data/lib/wsv/path_resolver.rb
CHANGED
|
@@ -50,6 +50,12 @@ module Wsv
|
|
|
50
50
|
decoded = decode(raw_path)
|
|
51
51
|
return Result.error(400) unless decoded
|
|
52
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.
|
|
53
59
|
relative = decoded.sub(%r{\A/+}, "")
|
|
54
60
|
return Result.error(403) if hidden_segment?(relative)
|
|
55
61
|
|
data/lib/wsv/request/parser.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Wsv
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def parse
|
|
16
|
-
line = @io.gets(REQUEST_LINE_LIMIT)
|
|
16
|
+
line = @io.gets("\n", REQUEST_LINE_LIMIT)
|
|
17
17
|
return :empty unless line
|
|
18
18
|
raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
|
|
19
19
|
|
|
@@ -30,7 +30,7 @@ module Wsv
|
|
|
30
30
|
headers = {}
|
|
31
31
|
total = 0
|
|
32
32
|
count = 0
|
|
33
|
-
while (line = @io.gets(HEADER_LINE_LIMIT))
|
|
33
|
+
while (line = @io.gets("\n", HEADER_LINE_LIMIT))
|
|
34
34
|
raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
|
|
35
35
|
|
|
36
36
|
stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
|
|
@@ -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
|
data/lib/wsv/request.rb
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "request/too_large"
|
|
3
4
|
require_relative "request/parser"
|
|
4
5
|
|
|
5
6
|
module Wsv
|
|
6
7
|
class Request
|
|
7
|
-
class TooLarge < StandardError
|
|
8
|
-
attr_reader :status_code
|
|
9
|
-
|
|
10
|
-
def initialize(status_code)
|
|
11
|
-
super("request exceeded size limit (#{status_code})")
|
|
12
|
-
@status_code = status_code
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
|
|
16
8
|
attr_reader :method, :target, :version, :headers
|
|
17
9
|
|
|
18
10
|
def initialize(method:, target:, version:, headers:)
|
|
@@ -6,17 +6,18 @@ require_relative "../mime_types"
|
|
|
6
6
|
module Wsv
|
|
7
7
|
class Response
|
|
8
8
|
class FileBuilder
|
|
9
|
-
def initialize(path, head: false, range: nil)
|
|
9
|
+
def initialize(path, head: false, range: nil, status: 200)
|
|
10
10
|
@path = path
|
|
11
11
|
@head = head
|
|
12
12
|
@range = range
|
|
13
|
+
@status = status
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def build
|
|
16
17
|
if @range
|
|
17
18
|
Response.new(status: 206, headers: range_headers, body: range_body)
|
|
18
19
|
else
|
|
19
|
-
Response.new(status:
|
|
20
|
+
Response.new(status: @status, headers: full_headers, body: full_body)
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
23
|
|
data/lib/wsv/response.rb
CHANGED
|
@@ -31,10 +31,19 @@ module Wsv
|
|
|
31
31
|
Status.reason(status)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Returns a new Response with `extra` merged into the headers, sharing the
|
|
35
|
+
# same body object so streaming (FileBody) is preserved.
|
|
36
|
+
def with_headers(extra)
|
|
37
|
+
self.class.new(status: @status, headers: @headers.merge(extra), body: @body)
|
|
38
|
+
end
|
|
39
|
+
|
|
34
40
|
def write_to(io)
|
|
35
41
|
io.write "HTTP/1.1 #{status} #{reason}\r\n"
|
|
36
42
|
io.write "Server: #{SERVER_NAME}\r\n"
|
|
37
43
|
io.write "Connection: close\r\n"
|
|
44
|
+
unless headers.any? { |name, _value| name.to_s.casecmp?("X-Content-Type-Options") }
|
|
45
|
+
io.write "X-Content-Type-Options: nosniff\r\n"
|
|
46
|
+
end
|
|
38
47
|
headers.each { |name, value| io.write "#{name}: #{value}\r\n" }
|
|
39
48
|
io.write "\r\n"
|
|
40
49
|
@body.write_to(io)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Server
|
|
5
|
+
# Renders the startup announcement (the "Serving / Bind / Local / Stop"
|
|
6
|
+
# block plus warnings about non-loopback binds and self-signed certs).
|
|
7
|
+
class Banner
|
|
8
|
+
def initialize(host:, port:, root:, out:, err:, tls:)
|
|
9
|
+
@host = host
|
|
10
|
+
@port = port
|
|
11
|
+
@root = root
|
|
12
|
+
@out = out
|
|
13
|
+
@err = err
|
|
14
|
+
@tls = tls
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def emit
|
|
18
|
+
@out.puts "Serving: #{@root}"
|
|
19
|
+
@out.puts "Bind: #{url_for(@host)}"
|
|
20
|
+
@out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(@host)
|
|
21
|
+
@out.puts "Stop: Ctrl-C"
|
|
22
|
+
warn_public_bind unless localhost?(@host)
|
|
23
|
+
warn_ephemeral_cert if @tls&.ephemeral?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def warn_public_bind
|
|
29
|
+
@err.puts "WARNING: binding to #{@host} exposes #{@root} on your network."
|
|
30
|
+
@err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def warn_ephemeral_cert
|
|
34
|
+
@err.puts "WARNING: serving with a self-signed certificate. Browsers will"
|
|
35
|
+
@err.puts " show a security warning. Pass --cert / --key for a real cert."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def url_for(display_host)
|
|
39
|
+
"#{scheme}://#{format_host(display_host)}:#{@port}/"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def format_host(host)
|
|
43
|
+
# Bracket IPv6 literals per RFC 3986; zone IDs (`%eth0` etc.) need %25 per RFC 6874.
|
|
44
|
+
host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def scheme
|
|
48
|
+
@tls ? "https" : "http"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def localhost?(display_host)
|
|
52
|
+
["127.0.0.1", "localhost", "::1"].include?(display_host)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Wsv
|
|
6
|
+
class Server
|
|
7
|
+
# Launches the OS default browser at the served URL when `--open` is set.
|
|
8
|
+
# Best-effort: unsupported platforms or spawn failures are logged but
|
|
9
|
+
# never abort the server.
|
|
10
|
+
class BrowserLauncher
|
|
11
|
+
def initialize(host:, port:, tls:, err:)
|
|
12
|
+
@host = host
|
|
13
|
+
@port = port
|
|
14
|
+
@tls = tls
|
|
15
|
+
@err = err
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def launch
|
|
19
|
+
command = platform_command
|
|
20
|
+
unless command
|
|
21
|
+
@err.puts "wsv: --open is not supported on this platform; skipping."
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
pid = Process.spawn(*command, url, in: :close, out: File::NULL, err: File::NULL)
|
|
26
|
+
Process.detach(pid)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
@err.puts "wsv: failed to open browser: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def url
|
|
34
|
+
scheme = @tls ? "https" : "http"
|
|
35
|
+
"#{scheme}://#{url_host}:#{@port}/"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def url_host
|
|
39
|
+
host = display_host
|
|
40
|
+
# IPv6 literals must be bracketed in URLs per RFC 3986. Scoped IPv6
|
|
41
|
+
# zone identifiers use `%`, which must be percent-encoded in URLs.
|
|
42
|
+
host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def display_host
|
|
46
|
+
# Wildcard binds aren't reachable; redirect to the matching loopback.
|
|
47
|
+
case @host
|
|
48
|
+
when "0.0.0.0" then "127.0.0.1"
|
|
49
|
+
when "::" then "::1"
|
|
50
|
+
else @host
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def platform_command
|
|
55
|
+
case RbConfig::CONFIG["host_os"]
|
|
56
|
+
when /darwin/ then ["open"]
|
|
57
|
+
when /linux|bsd/ then ["xdg-open"]
|
|
58
|
+
when /mswin|mingw|cygwin/ then ["cmd.exe", "/c", "start", ""]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Server
|
|
5
|
+
# Wraps an IO with a shared deadline so each subsequent read is bounded by
|
|
6
|
+
# the time remaining until the deadline. Used to enforce a single budget
|
|
7
|
+
# across the request line and all header lines.
|
|
8
|
+
class DeadlineReader
|
|
9
|
+
def initialize(io, deadline)
|
|
10
|
+
@io = io
|
|
11
|
+
@deadline = deadline
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def gets(eol, limit)
|
|
15
|
+
remaining = @deadline - Time.now
|
|
16
|
+
raise IO::TimeoutError if remaining <= 0
|
|
17
|
+
|
|
18
|
+
@io.to_io.timeout = remaining
|
|
19
|
+
@io.gets(eol, limit)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|