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 +4 -4
- data/CHANGELOG.md +76 -1
- data/README.md +91 -8
- data/lib/wsv/app.rb +109 -6
- data/lib/wsv/cli.rb +49 -3
- data/lib/wsv/cors.rb +30 -0
- data/lib/wsv/path_resolver.rb +14 -1
- data/lib/wsv/request/parser.rb +52 -0
- data/lib/wsv/request/too_large.rb +18 -0
- data/lib/wsv/request.rb +4 -47
- data/lib/wsv/response/file_body.rb +31 -0
- data/lib/wsv/response/file_builder.rb +63 -0
- data/lib/wsv/response/string_body.rb +23 -0
- data/lib/wsv/response/text_builder.rb +35 -0
- data/lib/wsv/response.rb +43 -29
- 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 +70 -38
- data/lib/wsv/status.rb +4 -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 +356 -2
- data/test/browser_launcher_test.rb +57 -0
- data/test/cli_test.rb +64 -0
- data/test/path_resolver_test.rb +35 -0
- data/test/response_test.rb +49 -0
- data/test/server_test.rb +84 -2
- data/test/tls_context_test.rb +169 -0
- metadata +17 -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,6 +1,81 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
|
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,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`
|
|
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
|
-
-
|
|
87
|
-
-
|
|
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" =>
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
file_response(result.file, request, head: head)
|
|
28
52
|
end
|
|
29
53
|
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|