Selaa lähdekoodia

Say what the private asset refusal actually is

The DirIndex POD implied the refusal to index www/assets/private was what kept
those files private.  It isn't.  On a correctly provisioned site nginx gates them
by auth_requesting the /authenticated route, which answers 200 for a request
carrying a live session and 403 otherwise -- that is the entire reason that route
exists, and it means pointing a listing at that directory would not hand the
files out at all.  A reader following one of the links still gets asked to log in.

What it would hand out is the names, which the owner probably did not mean to
publish either.  So the refusal is a guard rail against operator error, not a
protection: it turns a mistake made in a text field into a line in the log.  The
POD now says that, says the nginx configuration is what does the real work, and
says that none of it holds on a site provisioned some other way.

Trog::DataSource gets the general form of the lesson, since it applies to any
source somebody writes: a list of names is itself information, the names may be
more sensitive than the things, and the webserver is part of the security model
but is invisible from inside the application -- so do not lean on it and do not
try to replace it either.  design.md's auth section now mentions the route for
the same reason; it described the cookie and the ACLs but not the half that runs
outside the process.

Also unstack two POD headings that podchecker warned about while in here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6Q1gRJ2Rr85S8FQ47EjnQ
George Baugh 1 kuukausi sitten
vanhempi
sitoutus
08bf473c02
5 muutettua tiedostoa jossa 72 lisäystä ja 12 poistoa
  1. 8 3
      Readme.md
  2. 7 0
      design.md
  3. 23 0
      lib/Trog/DataSource.pm
  4. 31 8
      lib/Trog/DataSource/DirIndex.pm
  5. 3 1
      lib/Trog/DataSource/Virt.pm

+ 8 - 3
Readme.md

@@ -151,9 +151,14 @@ directory with its "Directory to index" field, build a child type in the wizard
 with DirIndex as its datasource, and every file in there becomes a post carrying
 its name, path, size, mtime, extension and content type -- so a downloads page, a
 gallery or a release listing is a template and nothing else.  It will only index
-directories under www/ (which the webserver already serves, so a listing
-discloses nothing a guessed URL would not), and never www/assets/private (whose
-files are gated, so its names are too).
+directories under www/ -- which the webserver already serves, so a listing
+discloses nothing a guessed URL would not -- and never www/assets/private.
+
+That last is a guard rail rather than a protection: nginx gates those files by
+auth_requesting the /authenticated route, so a listing of them would not hand the
+files out, only the names.  Sharing that directory is operator error, and
+refusing it turns a mistake in a text field into a line in the log.  See the POD
+in lib/Trog/DataSource/DirIndex.pm.
 
 A post type names one in its sidecar with x-tcms-datasource, and a series of that
 type then lists whatever the source builds instead of posts somebody wrote.  The

+ 7 - 0
design.md

@@ -347,6 +347,13 @@ Identity, configuration, operations
 
 **Auth** (`Trog::Auth`) is an SQLite database at `config/auth.db` holding hashed
 and salted passwords, ACLs and sessions. A session is the `tcmslogin` cookie.
+
+The webserver participates. `/authenticated` answers 200 for a request carrying a
+live session and 403 otherwise, and exists so that nginx can `auth_request`
+against it -- which is how private uploads under `www/assets/private` are gated
+without tCMS being in the path of the file itself. That protection is real and it
+is invisible from inside the application, so code that reasons about what is
+reachable should not assume it and should not try to replace it.
 TOTP two-factor is supported. The first user to register becomes the
 administrator and registration then closes; further users are made by an admin
 through the `about` post type -- because a user *is* their profile page.

+ 23 - 0
lib/Trog/DataSource.pm

@@ -48,6 +48,29 @@ order of their own that a date and a title don't capture.
 What the search box is searching, and where to read about it, for a page drawn
 from this source.  Defaulted below.
 
+=head1 A NOTE ON WHAT A LISTING DISCLOSES
+
+A datasource turns something into a list, and a list of names is itself
+information.  Two things follow that are worth thinking about before you write
+one.
+
+The first is that the names may be more sensitive than the things.  A file whose
+contents the webserver will refuse to serve still has a name, and publishing that
+name publishes the fact of it.  Decide deliberately what your source is willing
+to enumerate, rather than enumerating whatever it can reach and relying on
+whatever gates the contents.
+
+The second is that the webserver is part of the security model and you cannot see
+it from here.  On a correctly provisioned site nginx auth_requests the
+/authenticated route -- which answers 200 for a request carrying a live session
+and 403 otherwise -- and gates www/assets/private on the result.  That protection
+is real, it is why that route exists, and it is also invisible to this code and
+absent on a site provisioned some other way.  Do not lean on it, and do not
+duplicate it either: your job is to decide what to put in the list.
+
+Trog::DataSource::DirIndex is the worked example, and documents where it drew
+that line and why.
+
 =head1 FUNCTIONS
 
 =head2 filter($query, @posts) = @posts

+ 31 - 8
lib/Trog/DataSource/DirIndex.pm

@@ -47,14 +47,35 @@ Only directories under C<www/>, and never C<www/assets/private>.
 That is not arbitrary caution.  Everything under www/ is already served to
 anybody who asks, so listing it discloses nothing that a guessed URL would not --
 whereas a listing of anywhere else hands out the names of files the webserver
-would refuse to serve, and names are frequently the interesting part.  The one
-exception is the private asset directory, which is under www/ but gated by the
-webserver rather than by us: its files are protected, so its filenames should be
-too.
+would refuse to serve, and names are frequently the interesting part.
 
-Symlinks are resolved before that check, and entries which point outside the
-tree are left out of the listing rather than shown with a link that will not
-work.
+=head2 A NOTE ON www/assets/private
+
+The refusal is a guard rail against an operator mistake, and not the thing that
+makes private assets private.
+
+Private uploads live under www/assets/private, and on a correctly provisioned
+site nginx is what protects them: it auth_requests the /authenticated route,
+which answers 200 for a request carrying a live session and 403 for one that
+isn't, and serves the file or refuses it accordingly.  That is the entire reason
+that route exists.  So pointing a listing at that directory does not hand the
+files out -- a reader who follows one of the links still gets asked to log in.
+
+What it would hand out is the names, which the site owner probably did not mean
+to publish either, and which nothing else would stop.  Sharing that directory is
+operator error; refusing it here is cheap, and turns a mistake made in a text
+field into an error in the log rather than a disclosure nobody notices.  If you
+genuinely want to publish a listing of it, put the files somewhere public instead
+-- that is what the distinction between the two directories is for.
+
+None of this holds if the site is served without that nginx configuration.  See
+the nginxproxy recipe referenced in the Readme; several features besides this one
+assume it.
+
+=head2 SYMLINKS
+
+Resolved before the containment check, so entries pointing outside the tree are
+left out of the listing rather than shown with a link that will not work.
 
 =cut
 
@@ -292,9 +313,11 @@ sub order ( $query, @posts ) {
 
 =head2 lang() = STRING
 
+What the search box searches on a directory listing.
+
 =head2 help() = STRING
 
-What the search box searches on a directory listing.
+Where to read about it.
 
 =cut
 

+ 3 - 1
lib/Trog/DataSource/Virt.pm

@@ -211,9 +211,11 @@ sub filter ( $query, @posts ) {
 
 =head2 lang() = STRING
 
+What the search box searches on a page of guests.
+
 =head2 help() = STRING
 
-What the search box searches on a page of guests.
+Where to read about it.
 
 =cut