Просмотр исходного кода

Refuse local addresses unless -LOCAL is given

Replace -NOLOCAL with the safer default: local and private addresses are
refused, and a local -URL stops with a message saying to add -LOCAL. The
website converter never passes it. Move the example command from the
summary into Usage, and list -LOCAL in the site's usage table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 8 часов назад
Родитель
Сommit
c55acff0cb

+ 7 - 6
README.md

@@ -87,7 +87,7 @@ To try it on real articles, run `test-examples.cmd` (`.\test-examples.cmd` in Po
 ## Usage
 
 ```
-rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
+rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-LOCAL]
 rekopub -VERSION
 ```
 
@@ -97,7 +97,7 @@ rekopub -VERSION
 | `-SAVE` | yes | Where to write the book: a file or a folder (see below). Missing folders are created. |
 | `-FORCE` | no | Overwrite the output file if it already exists. |
 | `-KEPUB` | no | Write a Kobo KEPUB (`.kepub.epub`) instead of a plain EPUB. |
-| `-NOLOCAL` | no | Never connect to local or private addresses, even when `-URL` is one. For servers that run ReKoPub on behalf of other people. |
+| `-LOCAL` | no | Allow `-URL` to be on your own computer or a private network, such as a wiki at home. Without it, local and private addresses are refused. |
 | `-VERSION` | no | Print the version and exit. Include it when reporting a problem. |
 
 Flag names are case-sensitive: `-URL` works, `-url` does not.
@@ -187,7 +187,7 @@ Use plain EPUB, without `-KEPUB`.
 |---|---|
 | `0` | Success |
 | `1` | The page couldn't be fetched (network error, non-200 response, not an HTML page) |
-| `2` | Invalid arguments |
+| `2` | Invalid arguments, including a local or private `-URL` without `-LOCAL` |
 | `3` | No readable article content was found on the page |
 | `4` | The book couldn't be built or written (including folder creation) |
 | `5` | The output file already exists and `-FORCE` was not given |
@@ -215,7 +215,8 @@ done < urls.txt
 | `no readable article content found` | The page has no article text ReKoPub can find. Usually the page builds its content with JavaScript, or it's a paywall or cookie wall. |
 | `warning: image … skipped: …` | One image couldn't be downloaded or read. The book is still made, with the image's description in its place. |
 | `warning: cover image … skipped, using a plain cover` | The page's preview image couldn't be used, so the book gets a typographic cover instead. |
-| `… is a local or private address …` | The page tried to send ReKoPub to your own computer or network, through a redirect or an image. That's refused for pages on the internet (see [Privacy and security](#privacy-and-security)). To convert a page on your own network, give its local address as `-URL`. |
+| `-URL … is on this computer or a private network; add -LOCAL to convert it` | Local and private addresses are refused unless you add `-LOCAL` (see [Privacy and security](#privacy-and-security)). |
+| `… is a local or private address …` | The page tried to send ReKoPub to your own computer or network, through a redirect or an image. That's refused for pages on the internet, with or without `-LOCAL`. To convert a page on your own network, give its local address as `-URL` and add `-LOCAL`. |
 
 To report a problem with a particular page, include the output of `rekopub -VERSION`, the full command, everything it printed, and a saved copy of the page:
 
@@ -230,7 +231,7 @@ curl -L -A "Mozilla/5.0" -o page.html "https://example.com/the-article"
 ## Privacy and security
 
 - **Only web pages are read.** ReKoPub downloads the page given as `-URL`, its images and its preview image, over `http://` or `https://` only. It never reads files on your computer: `file:` links and local paths in a page are ignored, and the only file it writes is the book.
-- **Pages on the internet can't reach your network.** A web page chooses where its images come from, and can redirect. So when `-URL` is on the internet, ReKoPub refuses to connect to your own computer (`localhost`), your home or office network (such as `192.168.x.x` or `10.x.x.x`) and similar private addresses. If `-URL` itself is a local address, such as a wiki on your network, its local images are allowed. With a proxy set in `HTTP_PROXY` or `HTTPS_PROXY`, the proxy makes the connections, so ReKoPub can't check them. `-NOLOCAL` refuses local addresses even when `-URL` is one; the website converter always uses it.
+- **Your own network is off limits by default.** ReKoPub refuses to connect to your own computer (`localhost`), your home or office network (such as `192.168.x.x` or `10.x.x.x`) and similar private addresses. To convert a page there, such as a wiki on your network, add `-LOCAL`; that page's local images are then allowed too. A web page chooses where its images come from, and can redirect, so pages on the internet can never make ReKoPub connect to those addresses, with or without `-LOCAL`. With a proxy set in `HTTP_PROXY` or `HTTPS_PROXY`, the proxy makes the connections, so ReKoPub can't check them. The website converter never uses `-LOCAL`.
 - **What sites see.** Requests look like a desktop browser and include "ReKoPub" in the user agent. Image requests send the article's address as the referrer, as browsers do. ReKoPub sends no cookies, never logs in and runs no JavaScript.
 - **Your files are safe.** Existing files are only replaced with `-FORCE`. Filenames made from article titles contain only letters, digits and dashes, so a title can't place the book outside the folder you chose.
 
@@ -305,7 +306,7 @@ The form on the site sends the address and format to `rekopub-web`, which runs a
 
 1. checks the address: `http://` or `https://`, standard ports, no user name or password, not a local or private address;
 2. checks the limits: by default 50 conversions in 15 minutes for everyone together and 10 per visitor (an IPv4 address, or an IPv6 /64), 2 running at once with up to 10 waiting, and 2 GB of kept books. When a limit is reached the form is paused and says when to try again;
-3. runs `rekopub -NOLOCAL` (with `-KEPUB` for KEPUB, both for "Both") as a separate process with a 2-minute time limit and an empty environment, saving into a new folder with a random 128-bit name in `data/books/`;
+3. runs `rekopub`, never with `-LOCAL` (with `-KEPUB` for KEPUB, both for "Both") as a separate process with a 2-minute time limit and an empty environment, saving into a new folder with a random 128-bit name in `data/books/`;
 4. answers with the download links, `/books/<folder>/<file>`. Books are deleted 24 hours later.
 
 Settings come from `/etc/goapps/rekopub-web.env` (see [`deploy/rekopub-web.env.example`](deploy/rekopub-web.env.example) and `cmd/rekopub-web/main.go`). To run it locally, next to a built `rekopub`:

+ 12 - 9
cmd/rekopub/main.go

@@ -1,6 +1,6 @@
 // Command rekopub converts a web article into an EPUB for e-ink readers.
 //
-//	rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
+//	rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-LOCAL]
 package main
 
 import (
@@ -31,7 +31,7 @@ const (
 	exitInterrupted = 130
 )
 
-const usage = `Usage: rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
+const usage = `Usage: rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-LOCAL]
 
 Convert a web article into an EPUB for e-ink readers.
 
@@ -48,9 +48,10 @@ Convert a web article into an EPUB for e-ink readers.
         Overwrite the output file if it already exists.
   -KEPUB
         Write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB.
-  -NOLOCAL
-        Never connect to local or private addresses, even when -URL is
-        one. For servers that run rekopub on behalf of other people.
+  -LOCAL
+        Allow -URL to be on this computer or a private network, such as
+        a wiki at home. Without it, local and private addresses are
+        refused.
   -VERSION
         Print the version and exit.
 `
@@ -74,7 +75,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert c
 	saveArg := fs.String("SAVE", "", "file or folder to write the book to (required)")
 	force := fs.Bool("FORCE", false, "overwrite the output file if it already exists")
 	kepub := fs.Bool("KEPUB", false, "write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB")
-	noLocal := fs.Bool("NOLOCAL", false, "never connect to local or private addresses")
+	local := fs.Bool("LOCAL", false, "allow -URL to be a local or private address")
 	showVersion := fs.Bool("VERSION", false, "print the version and exit")
 
 	if err := fs.Parse(args); err != nil {
@@ -127,9 +128,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert c
 	}
 
 	opts := pipeline.Options{
-		Warn:    func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
-		KEPUB:   *kepub,
-		NoLocal: *noLocal,
+		Warn:  func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
+		KEPUB: *kepub,
+		Local: *local,
 	}
 	if !target.Known() && !*force {
 		// For folder targets the filename comes from the title; check it
@@ -142,6 +143,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert c
 		return fail(exitInterrupted, "interrupted")
 	case errors.Is(err, save.ErrExists):
 		return saveFailure(fail, err)
+	case errors.Is(err, pipeline.ErrLocal):
+		return fail(exitUsage, "-URL %s is on this computer or a private network; add -LOCAL to convert it", pageURL.Hostname())
 	case errors.Is(err, pipeline.ErrFetch):
 		return fail(exitFetch, "%v", err)
 	case errors.Is(err, pipeline.ErrNoContent):

+ 25 - 23
cmd/rekopub/main_test.go

@@ -24,17 +24,17 @@ import (
 // fakeConverter returns a fixed book and counts how often it was called, so
 // tests can check that nothing is fetched when arguments are rejected.
 type fakeConverter struct {
-	title   string
-	err     error
-	calls   int
-	kepub   bool // the KEPUB option of the last call
-	noLocal bool // the NoLocal option of the last call
+	title string
+	err   error
+	calls int
+	kepub bool // the KEPUB option of the last call
+	local bool // the Local option of the last call
 }
 
 func (f *fakeConverter) convert(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error) {
 	f.calls++
 	f.kepub = opts.KEPUB
-	f.noLocal = opts.NoLocal
+	f.local = opts.Local
 	if f.err != nil {
 		return nil, f.err
 	}
@@ -280,25 +280,25 @@ func TestKEPUBOption(t *testing.T) {
 	}
 }
 
-func TestNoLocalOption(t *testing.T) {
-	for _, noLocal := range []bool{false, true} {
+func TestLocalOption(t *testing.T) {
+	for _, local := range []bool{false, true} {
 		conv := &fakeConverter{title: "Title"}
 		args := []string{"-URL", testURL, "-SAVE", t.TempDir()}
-		if noLocal {
-			args = append(args, "-NOLOCAL")
+		if local {
+			args = append(args, "-LOCAL")
 		}
 		if r := runCLI(t, conv, args...); r.code != exitOK {
-			t.Fatalf("noLocal=%v: exit code = %d, stderr = %q", noLocal, r.code, r.stderr)
+			t.Fatalf("local=%v: exit code = %d, stderr = %q", local, r.code, r.stderr)
 		}
-		if conv.noLocal != noLocal {
-			t.Errorf("noLocal=%v: pipeline got NoLocal=%v", noLocal, conv.noLocal)
+		if conv.local != local {
+			t.Errorf("local=%v: pipeline got Local=%v", local, conv.local)
 		}
 	}
 }
 
-// TestNoLocalRefusesLocalPage runs the real pipeline: without -NOLOCAL a
-// page on localhost may be converted, with it the page isn't even fetched.
-func TestNoLocalRefusesLocalPage(t *testing.T) {
+// TestLocalPageNeedsFlag runs the real pipeline: a page on localhost is
+// refused without -LOCAL, before anything is fetched, and converted with it.
+func TestLocalPageNeedsFlag(t *testing.T) {
 	blog, err := os.ReadFile("../../testdata/pages/blog.html")
 	if err != nil {
 		t.Fatal(err)
@@ -313,9 +313,10 @@ func TestNoLocalRefusesLocalPage(t *testing.T) {
 
 	dir := t.TempDir()
 	var stdout, stderr bytes.Buffer
-	code := run(context.Background(), []string{"-URL", srv.URL + "/post", "-SAVE", dir, "-NOLOCAL"}, &stdout, &stderr, pipeline.Run)
-	if code != exitFetch || !strings.Contains(stderr.String(), "local or private address") {
-		t.Errorf("exit code = %d, stderr = %q; want %d and a refused local address", code, stderr.String(), exitFetch)
+	code := run(context.Background(), []string{"-URL", srv.URL + "/post", "-SAVE", dir}, &stdout, &stderr, pipeline.Run)
+	want := "error: -URL 127.0.0.1 is on this computer or a private network; add -LOCAL to convert it\n"
+	if code != exitUsage || stderr.String() != want {
+		t.Errorf("exit code = %d, stderr = %q; want %d and %q", code, stderr.String(), exitUsage, want)
 	}
 	if n := requests.Load(); n != 0 {
 		t.Errorf("server got %d requests, want 0", n)
@@ -324,8 +325,8 @@ func TestNoLocalRefusesLocalPage(t *testing.T) {
 
 	stdout.Reset()
 	stderr.Reset()
-	if code := run(context.Background(), []string{"-URL", srv.URL + "/post", "-SAVE", dir}, &stdout, &stderr, pipeline.Run); code != exitOK {
-		t.Errorf("without -NOLOCAL: exit code = %d, stderr = %q", code, stderr.String())
+	if code := run(context.Background(), []string{"-URL", srv.URL + "/post", "-SAVE", dir, "-LOCAL"}, &stdout, &stderr, pipeline.Run); code != exitOK {
+		t.Errorf("with -LOCAL: exit code = %d, stderr = %q", code, stderr.String())
 	}
 }
 
@@ -467,7 +468,8 @@ func TestEndToEnd(t *testing.T) {
 
 	realRun := func(args ...string) result {
 		var stdout, stderr bytes.Buffer
-		code := run(context.Background(), args, &stdout, &stderr, pipeline.Run)
+		// The test server is on localhost.
+		code := run(context.Background(), append(args, "-LOCAL"), &stdout, &stderr, pipeline.Run)
 		return result{code, stdout.String(), stderr.String()}
 	}
 
@@ -825,7 +827,7 @@ func TestLocalFilesNeverRead(t *testing.T) {
 
 	out := filepath.Join(t.TempDir(), "book.epub")
 	var stdout, stderr bytes.Buffer
-	code := run(context.Background(), []string{"-URL", srv.URL + "/posts/eink", "-SAVE", out}, &stdout, &stderr, pipeline.Run)
+	code := run(context.Background(), []string{"-URL", srv.URL + "/posts/eink", "-SAVE", out, "-LOCAL"}, &stdout, &stderr, pipeline.Run)
 	if code != exitOK {
 		t.Fatalf("exit %d: %s", code, stderr.String())
 	}

+ 1 - 1
internal/fetch/errors.go

@@ -32,7 +32,7 @@ func Explain(err error) error {
 	var invalidCert x509.CertificateInvalidError
 	switch {
 	case errors.As(err, &blocked):
-		return &explained{fmt.Sprintf("%s is a local or private address (%s); rekopub connects to those only when -URL is a local address too", orServer(host), blocked.Addr), err}
+		return &explained{fmt.Sprintf("%s is a local or private address (%s); rekopub connects to those only with -LOCAL, when -URL is a local address too", orServer(host), blocked.Addr), err}
 	case errors.As(err, &dnsErr):
 		return &explained{fmt.Sprintf("can't find the server %s; check the address and your internet connection", dnsErr.Name), err}
 	case isRefused(err):

+ 15 - 7
internal/pipeline/pipeline.go

@@ -27,6 +27,8 @@ import (
 var (
 	ErrFetch     = errors.New("couldn't fetch the page")
 	ErrNoContent = extract.ErrNoContent
+	// ErrLocal reports a local or private -URL without Options.Local.
+	ErrLocal = errors.New("local or private address")
 )
 
 // Book is a finished book, ready to be saved.
@@ -49,17 +51,23 @@ type Options struct {
 	Warn func(msg string)
 	// KEPUB converts the finished book to a Kobo KEPUB.
 	KEPUB bool
-	// NoLocal refuses local and private addresses even when u itself is
-	// one. A server that converts pages for other people sets it, so a
-	// visitor can't make it connect to services on the server's network.
-	NoLocal bool
+	// Local allows u to be a local or private address, such as a wiki on
+	// the reader's network; its local images and redirects are allowed too.
+	// Without it, local and private addresses are always refused, so a
+	// page can never make rekopub connect to the computer it runs on or
+	// its network.
+	Local bool
 }
 
 // Run converts the page at u into a book using the default settings.
-// Images and redirects may lead to local and private addresses only when
-// u itself is local and opts.NoLocal isn't set.
+// Local and private addresses are allowed only with opts.Local and when u
+// itself is local; a local u without opts.Local returns ErrLocal.
 func Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
-	return New(!opts.NoLocal && fetch.IsLocal(ctx, u.Hostname())).Run(ctx, u, opts)
+	local := fetch.IsLocal(ctx, u.Hostname())
+	if local && !opts.Local {
+		return nil, fmt.Errorf("%w: %s", ErrLocal, u.Hostname())
+	}
+	return New(local).Run(ctx, u, opts)
 }
 
 // Pipeline holds the settings for conversions.

+ 1 - 1
internal/web/run.go

@@ -72,7 +72,7 @@ func explain(ctx context.Context, res runResult) string {
 	switch {
 	case errors.Is(ctx.Err(), context.DeadlineExceeded):
 		return "That page took too long to convert."
-	case res.Code == exitFetch && strings.Contains(res.Err, "local or private address"):
+	case strings.Contains(res.Err, "local or private") || strings.Contains(res.Err, "-LOCAL"):
 		return "That page leads to a local or private network address, which this site doesn't connect to."
 	case res.Code == exitFetch && res.Err != "":
 		msg := strings.TrimPrefix(res.Err, "couldn't fetch the page: ")

+ 5 - 3
internal/web/server.go

@@ -212,8 +212,9 @@ func (s *Server) convert(w http.ResponseWriter, r *http.Request) {
 		s.respond(w, r, http.StatusBadRequest, reply{Error: msg})
 		return
 	}
-	// The command refuses local addresses anyway (-NOLOCAL); checking here
-	// too gives a clear message and doesn't use up a conversion.
+	// The command refuses local addresses anyway (it's run without
+	// -LOCAL); checking here too gives a clear message and doesn't use up
+	// a conversion.
 	if s.isLocal(r.Context(), u.Hostname()) {
 		s.respond(w, r, http.StatusBadRequest, reply{Error: "That address is on a local or private network, which this site doesn't connect to."})
 		return
@@ -282,7 +283,8 @@ func (s *Server) runOnce(ctx context.Context, u *url.URL, dir string, kepub bool
 	ctx, cancel := context.WithTimeout(ctx, s.cfg.RunTimeout)
 	defer cancel()
 	// "-FLAG=value" keeps a value from ever being read as a flag.
-	args := []string{"-NOLOCAL", "-URL=" + u.String(), "-SAVE=" + dir + string(filepath.Separator)}
+	// Never -LOCAL: visitors mustn't reach the server's own network.
+	args := []string{"-URL=" + u.String(), "-SAVE=" + dir + string(filepath.Separator)}
 	if kepub {
 		args = append(args, "-KEPUB")
 	}

+ 7 - 7
internal/web/server_test.go

@@ -150,9 +150,8 @@ func TestConvertEPUB(t *testing.T) {
 		t.Errorf("file = %+v", f)
 	}
 	args := ts.cli.calls[0]
-	if args[0] != "-NOLOCAL" || args[1] != "-URL=https://example.com/posts/hello" ||
-		!strings.HasPrefix(args[2], "-SAVE="+ts.cfg.Dir+string(filepath.Separator)) || !strings.HasSuffix(args[2], string(filepath.Separator)) ||
-		slices.Contains(args, "-KEPUB") {
+	if len(args) != 2 || args[0] != "-URL=https://example.com/posts/hello" ||
+		!strings.HasPrefix(args[1], "-SAVE="+ts.cfg.Dir+string(filepath.Separator)) || !strings.HasSuffix(args[1], string(filepath.Separator)) {
 		t.Errorf("command args = %q", args)
 	}
 
@@ -375,6 +374,7 @@ func TestExplain(t *testing.T) {
 	}{
 		{runResult{Code: 1, Err: "couldn't fetch the page: example.com returned 404 Not Found"}, "Couldn't download the page: example.com returned 404 Not Found"},
 		{runResult{Code: 1, Err: "couldn't fetch the page: example.com is a local or private address (10.0.0.1); …"}, "local or private network address"},
+		{runResult{Code: 2, Err: "-URL 10.0.0.1 is on this computer or a private network; add -LOCAL to convert it"}, "local or private network address"},
 		{runResult{Code: 3}, "No readable article"},
 		{runResult{Code: 4, Err: "saving: /opt/apps/x: permission denied"}, "Something went wrong"},
 		{runResult{Code: -1}, "Something went wrong"},
@@ -514,16 +514,16 @@ func TestExecRunner(t *testing.T) {
 	script := filepath.Join(dir, "rekopub")
 	body := "#!/bin/sh\n" +
 		`[ -z "$REKOPUB_TEST_SECRET$HOME" ] || { echo "error: environment not empty" >&2; exit 4; }` + "\n" +
-		`case "$2" in *fail*) echo "warning: one image" >&2; echo "error: couldn't fetch the page: example.com returned 404 Not Found" >&2; exit 1;; esac` + "\n" +
-		`echo "$3" | cut -c7-` + "\n"
+		`case "$1" in *fail*) echo "warning: one image" >&2; echo "error: couldn't fetch the page: example.com returned 404 Not Found" >&2; exit 1;; esac` + "\n" +
+		`echo "$2" | cut -c7-` + "\n"
 	if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
 		t.Fatal(err)
 	}
 	run := execRunner(script)
-	if res := run(context.Background(), []string{"-NOLOCAL", "-URL=https://example.com/ok", "-SAVE=/x/a.epub"}); res.Code != 0 || res.Path != "/x/a.epub" {
+	if res := run(context.Background(), []string{"-URL=https://example.com/ok", "-SAVE=/x/a.epub"}); res.Code != 0 || res.Path != "/x/a.epub" {
 		t.Errorf("ok run = %+v", res)
 	}
-	res := run(context.Background(), []string{"-NOLOCAL", "-URL=https://example.com/fail", "-SAVE=/x/"})
+	res := run(context.Background(), []string{"-URL=https://example.com/fail", "-SAVE=/x/"})
 	if res.Code != 1 || res.Err != "couldn't fetch the page: example.com returned 404 Not Found" {
 		t.Errorf("failed run = %+v", res)
 	}

+ 12 - 6
pull-releases/003-web-convert/README.md

@@ -20,12 +20,17 @@ Convert articles on the website (v1.2.0)
     deleted.
   - nginx adds a per-address rate limit in front (6 conversion requests a
     minute, burst 5).
-- **New CLI flag `-NOLOCAL`**: refuses local and private addresses even when
-  `-URL` is one. Without it, `rekopub -URL http://localtest.me:3000/` turns
-  the server's own Gogs page into a book. The website always uses it, and
-  also checks the address itself before running anything.
+- **Local addresses now need `-LOCAL`** (CLI change): local and private
+  addresses are refused by default. A local `-URL` without the flag stops
+  with "-URL … is on this computer or a private network; add -LOCAL to
+  convert it" (exit code 2), before anything is downloaded. Before this,
+  `rekopub -URL http://localtest.me:3000/` turned the server's own Gogs page
+  into a book. The website never passes `-LOCAL`, and also checks the
+  address itself before running anything. People who convert pages on
+  their own network need to add `-LOCAL` from this version on.
 - **"Download / Use ReKoPub"** section title above Download and Usage, which
-  become its subsections.
+  become its subsections. The example command moved from the summary into
+  Usage, and the usage table lists `-LOCAL`.
 - `deploy/`: the nginx site file and an example settings file.
 - `VERSION` 1.2.0.
 
@@ -46,7 +51,8 @@ Convert articles on the website (v1.2.0)
 ## Testing
 
 - `go vet ./...`, `go test ./...` and `go test -race ./internal/web`.
-- New tests: `-NOLOCAL` (with the real pipeline against a local server),
+- New tests: `-LOCAL` (with the real pipeline: a localhost page is refused
+  without it, before any request, and converted with it),
   and the server: form checks, local addresses, both limits, busy, storage
   limit, failed conversions, unexpected output paths, link expiry and
   path tricks on `/books/`, deleting old books, HTML answers (escaped),

+ 6 - 3
site/index.html

@@ -38,7 +38,6 @@
         It is built for the <strong>reMarkable 2</strong> and the
         <strong>Kobo Clara HD</strong>.
       </p>
-      <pre><code>rekopub -URL https://example.com/some-article -SAVE ~/Books/</code></pre>
     </section>
 
     <section id="convert" aria-labelledby="convert-title">
@@ -146,7 +145,10 @@
 
       <section id="usage" aria-labelledby="usage-title">
         <h3 id="usage-title" class="label">Usage</h3>
-        <pre><code>rekopub -URL &lt;url&gt; -SAVE &lt;path&gt; [-FORCE] [-KEPUB]
+        <p>Give it an article's address and a folder for the book:</p>
+        <pre><code>rekopub -URL https://example.com/some-article -SAVE ~/Books/</code></pre>
+        <p>All the options:</p>
+        <pre><code>rekopub -URL &lt;url&gt; -SAVE &lt;path&gt; [-FORCE] [-KEPUB] [-LOCAL]
 rekopub -VERSION</code></pre>
         <div class="table-scroll">
           <table>
@@ -158,6 +160,7 @@ rekopub -VERSION</code></pre>
               <tr><td><code>-SAVE</code></td><td>Where to write the book: a file or a folder. Missing folders are created. Required.</td></tr>
               <tr><td><code>-FORCE</code></td><td>Overwrite the output file if it already exists.</td></tr>
               <tr><td><code>-KEPUB</code></td><td>Write a Kobo KEPUB (<code>.kepub.epub</code>) instead of a plain EPUB.</td></tr>
+              <tr><td><code>-LOCAL</code></td><td>Allow a <code>-URL</code> on your own computer or network, such as a wiki at home. Without it, those addresses are refused.</td></tr>
               <tr><td><code>-VERSION</code></td><td>Print the version and exit.</td></tr>
             </tbody>
           </table>
@@ -199,7 +202,7 @@ rekopub.exe -URL https://example.com/article -SAVE E:\Articles\ -KEPUB</code></p
         <h4>Good to know</h4>
         <ul>
           <li>Pages that build their content with JavaScript, paywalls and cookie walls can't be converted.</li>
-          <li>ReKoPub only reads web pages: it never reads files on your computer, and pages on the internet can't make it connect to your own network.</li>
+          <li>ReKoPub only reads web pages: it never reads files on your computer, and it doesn't connect to your own computer or network unless you add <code>-LOCAL</code>. Even then, pages on the internet can't make it connect there.</li>
           <li>It sends no cookies, never logs in and runs no JavaScript. Every book records its source URL.</li>
         </ul>
         <p>