Commit graph

21 commits

Author SHA1 Message Date
Roland
4b034ae3ee
repos: submodule links must stay in the repo; import checks before it moves
* A submodule's working-tree `.git` names the git directory git uses for
  it.  Before any submodule command (update, or deinit when submodules
  are turned off), and before an import, every `.git` below the top of
  the working tree must be a file pointing inside the repo's own
  .git/modules; a link, a directory, or a file pointing elsewhere could
  make git use a config that was never checked.
* Import validates and sanitizes the clone where it is, then moves it,
  and moves it back if marking it fails, so a refused or failed import
  never leaves a clone moved without a Repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:03:07 -06:00
Roland
d0ea6ab313
repos: a clone is owned by a random token, not its URL
The ownership marker held the Repo's URL key, which anyone can write, so
a copied clone, an extracted archive or a hand-made `.git` passed the
check and was reset and cleaned.  Each Repo now has a random clone_token
(repository column and repos.yaml, so a Repo restored from the config
still owns its clone); the marker holds it, and only a clone holding the
Repo's token is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:58:18 -06:00
Roland
a1d4c42eee
repos: find submodule git directories the way git does
A submodule git directory which borrows another (commondir) has no
objects of its own, and was skipped instead of refused, because
"has objects/" decided what a git directory was.  The walk now follows
git's layout: under .git/modules every directory holding HEAD is a git
directory (whatever the submodule's name, even `refs`), which must not
borrow, must have its own objects and a regular config, and is
sanitized; nested submodules are found in its own `modules`, and its
refs and logs are never mistaken for git directories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:28:31 -06:00
Roland
9e8947767b
repos: hold submodule git directories to the same rules as the repo's
The checks covered .git but not the submodules' git directories in
.git/modules: a linked submodule config was skipped (and still read by
git), and a submodule's commondir or alternates were not refused.  Every
git directory now needs a regular config and must not borrow another
directory, and nothing under .git/modules may be a link; otherwise the
update fails.  A git directory is one with HEAD and objects (a ref named
HEAD is not one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:24:48 -06:00
Roland
d3cf1b7282
repos: close the remaining ways a clone's .git can redirect git
* A clone with .git/commondir (git then reads another directory's config,
  refs and objects, so the sanitized config is not the one read) or
  alternates is neither imported nor updated.
* branch.*.remote/merge are no longer kept: WROLPi checks out with -B and
  needs neither, and branch.<name>.remote can be a URL git uses to resolve
  relative submodule URLs.
* An import directory is resolved and confined to the media directory
  before anything about it is reported, so a link cannot reveal whether a
  path outside exists.
* git_command also turns off automatic maintenance (gc.auto,
  maintenance.auto) and core.alternateRefsCommand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:18:39 -06:00
Roland
a79278e048
repos: never write through links in a clone's .git
A clone being imported is controlled by whoever put it in the media
directory.  A link at .git/wrolpi-repo or .git/config.wrolpi made the
marker and the sanitized config write wherever it pointed.  The marker
is written with O_NOFOLLOW (after removing a link), the sanitized config
through mkstemp, and a .git/config which is not a regular file is
refused (by Import, and before an update).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:13:21 -06:00
Roland
a94261276c
repos: import an existing git clone as a Repo
POST /api/repos/import/inspect reports what importing a clone in the
media directory would do, without changing it: its origin (an SSH or
git:// origin is converted to https://), branch, latest commit, commits
not on the remote, whether it is shallow, and where it will be moved.
It refuses special directories, Collections' directories, `.git`
files/links, bare repos, and partial clones.  It runs no git command
which could run a program from the clone's config (the latest commit is
read with cat-file, not log).

POST /api/repos/import (confirmed, with a URL that is the clone's
origin) moves the clone into the Repos directory (the old location is
refreshed away), sanitizes its config, points origin at the URL, marks
it as WROLPi's, and creates the Repo and its Download.  The Repo shows
the clone's commit and README at once; its first update makes it a
mirror (local work is discarded, and the page warns of local commits).

The Repos page has an Import button; the Import page inspects the
chosen directory, then imports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:09:52 -06:00
Roland
68880463eb
repos: write sanitized git configs directly; one place formats a Repo's directory
* The sanitized config is written as INI by WROLPi (values quoted), so
  no kept value is ever passed to `git config` as an argument.
* origin's url and fetch refspec are set with --replace-all (a planted
  duplicate made `git config key value` fail), in both modes.
* format_repo_destination formats a Repo's directory from its name, Tag,
  owner and host, so a directory can be known before the Repo exists
  (Import shows it); Collection.format_destination uses it for repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:59:32 -06:00
Roland
1e084958ee
repos: only update clones WROLPi made, from the Repo's URL
An update resets and cleans the clone, and fetched from whatever the
clone's `origin` said, trusting its .git/config.  A clone WROLPi did not
make (in the Repo's directory, or swapped in) could lose local work, be
mirrored from another repo, or redirect git with planted settings
(include.path, url.*.insteadOf, http.proxy, core.sshCommand, ...).

* WROLPi marks its clones (.git/wrolpi-repo holds the Repo's URL key);
  an unmarked clone, or `.git` that is not a directory, is not updated.
* Every update fetches the Repo's URL (origin is reset to it).
* Before an update, the clone's config (and its submodules') keeps only
  the settings WROLPi needs; a submodule's work tree must be inside the
  repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:51:16 -06:00
Roland
0676535b24
repos: ignore the repos destination's own directory, and validate it
The constant `repos` was always ignored, but the Repos Directory
setting accepted any relative template (`library/%(repo_name)s`,
`%(repo_name)s`), whose checkouts (and .git) would be indexed.

The fixed directory at the start of the destination is now the ignored
one (on config import and when the setting is saved).  A destination
must start with a fixed directory, contain %(repo_name)s, use only the
repo variables, and not overlap another special directory; a
hand-edited config that breaks these rules falls back to the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:45:05 -06:00
Roland
00d7816cfd
repos: pin the repo's host once, and only exempt its own host and port
A submodule on the repo's host was allowed unpinned, on any port.  The
owner of the repo's domain could serve the clone from a public address,
then answer the submodule's lookup with an internal address (DNS
rebinding), or point it at another port on that name.

The repo's host is now resolved once per download, and every fetch
(ls-remote, clone, fetch, submodule update) is pinned to that answer.  A
submodule on the repo's host *and port* is fetched from those same
addresses, still allowing a LAN forge the user chose; another port on
that name is checked like any other host.  Tests no longer use real DNS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:38:45 -06:00
Roland
420bd8295b
repos: a submodule host must be public unicast, not just is_global
`ipaddress.is_global` accepts multicast (224.0.0.1), deprecated
site-local (fec0::/10), and NAT64 addresses which carry a private IPv4
(64:ff9b::169.254.169.254, the metadata address).  is_public_address
refuses multicast, reserved, link-local, loopback, private, unspecified,
site-local, local-use NAT64 and Teredo addresses, and checks the IPv4
inside NAT64, 6to4 and IPv4-mapped addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:34:57 -06:00
Roland
1a28a5900a
repos: git inherits only an allow-listed environment
git_env copied the whole process environment, and git consults its
environment ahead of `-c`: GIT_ALLOW_PROTOCOL=file re-enabled file://
despite protocol.allow=never, and GIT_CONFIG_COUNT/KEY_*/VALUE_* could
add an `insteadOf` that rewrites an approved URL to another host.  Only
PATH, HOME, TMPDIR, TZ and the SSL certificate paths are inherited now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:32:59 -06:00
Roland
2dd8e908c2
repos: check submodule URLs where git resolves them
A relative submodule URL is resolved by git against the repo's remote,
and can climb to another host: with the remote
https://git.example.com/owner/repo, `../../../10.0.0.5/x` is
https://10.0.0.5/x.  Relative URLs were allowed unchecked.

git now resolves the URLs first (`submodule init` + `sync` write them to
the repo's config), and those resolved URLs, the ones git fetches, are
checked.  A relative URL is never allowed by the check itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:10 -06:00
Roland
4810b8bc50
repos: close the remaining submodule SSRF gaps
* .gitmodules is read NUL-delimited, and an unreadable record refuses
  the submodules.  A submodule name with a space (`[submodule "a b"]`)
  made `--get-regexp`'s "key value" output misfile its URL as a path, so
  the URL was never checked.
* Submodule URLs must have a strict shape every parser reads the same
  way (no userinfo, backslash, query, fragment, or whitespace), so a URL
  cannot be checked as one host and fetched from another.
* Each checked host is pinned (http.curloptResolve) to the addresses
  that were checked, so DNS cannot change between the check and git's
  fetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:10 -06:00
Roland
3b6e962dfc
repos: never fetch submodules from WROLPi's own network
A repo's .gitmodules is chosen by whoever controls the repo, so a
submodule URL could make WROLPi fetch https://localhost:8443/...,
https://10.0.0.1/..., etc. (SSRF), and show the response's error.

Every submodule URL is now checked before git fetches it: relative URLs,
the repo's own host, and hosts whose addresses are all public are
allowed; anything else defers the download with the reason.  Nested
submodules are checked out one level at a time (instead of git's
--recursive) so each level is checked, at most 5 levels deep.  Submodule
fetches do not follow HTTP redirects, so a checked host cannot redirect
git into the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:09 -06:00
Roland
03e3476631
repos: history, Download ZIP, and submodules
* GET /api/repos/<id>/log: the checked out branch's commits, newest
  first, a page at a time, with the total.  The repo page has a
  Files | History toggle (the tree is only listed for Files).
* GET /api/repos/<id>/archive.zip: `git archive HEAD` streamed to the
  browser; the file name is reduced to safe characters.  Everything is
  read from the database before the response starts, because the
  request's session closes once a streamed response begins.
* repository.submodules (default off): submodules are checked out with
  the repo's mode and the same git restrictions, and removed when the
  option is turned off.  A submodule that cannot be downloaded (e.g. an
  ssh URL) does not stop the repo from updating; the download is
  retried with the submodule's error.
* The README speed test bound is loose enough for a busy machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:09 -06:00
Roland
5506dc0a88
repos: harden README search against untrusted READMEs
A README comes from an untrusted remote.

* Escape the README headline: snippet() marks matches with control
  characters, the text is HTML-escaped, then the markers become <b>.
  Headlines are rendered as HTML (HeadlineText).
* Never follow a symlinked README (O_NOFOLLOW, regular files only).
* Convert markdown line by line with linear-time patterns.  The old
  multiline patterns backtracked polynomially; a README of blank lines
  never finished, and runs of `<!--`, `![` or `<` were quadratic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:09 -06:00
Roland
5c80ad7ba4
repos: search Repos by name and README
repository_fts (FTS5, external content) indexes each Repo's search_name
(name and owner) and readme_text; a name match outranks a README
mention.  The downloader stores the README as plain text (markdown
syntax removed so snippets read well), writing it only when it changed.

* POST /api/repos/search: by search and/or Tag, with README headlines.
* /api/search_other_estimates takes an optional search_str and returns
  repo_count, so Repos count toward the global search's Other tab.
* /api/search_suggestions suggests Repos by name.

The repository FTS DDL is installed only once the repository table
exists: the baseline migration also runs install_raw_ddl, before that
table is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:09 -06:00
Roland
f49288b8ec
repos: validate branch names and repo directories
Branch names are passed to git as arguments and come from the API, the
config, a `/tree/<branch>` URL, or the remote itself (its default
branch).  A name starting with `-` would be read as an option, so every
branch must pass git's ref-name rules, including in the downloader just
before use.

A repo URL with a `.` or `..` path segment (or a `..` name) could format
a directory outside its template (e.g. `repos/<tag>/..` is the whole
repos directory).  Such URLs and names are rejected, and every repo
directory, including one from repos.yaml, must be strictly inside the
media directory with no `..`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:09 -06:00
Roland
b3255e715c
Add a Repos module that mirrors git repositories (backend)
A Repo is a Collection (kind='repo') plus a `repository` row, kept up to
date by a recurring Download handled by the new `git` downloader.

* Full history by default (all branches and tags, no --prune; a force
  push saves the old HEAD under refs/wrolpi/backup/); snapshot mode
  (--depth 1) and a pinned branch are optional.  `/tree/<branch>` URLs
  select a branch.
* git runs HTTPS-only, never prompts, never runs hooks, and checks out
  symlinks as plain files.  Upstream disappearing defers the download
  with git's error; the local copy is kept.
* Repos are cloned to `repos_destination` (repos/%(repo_tag)s/%(repo_name)s)
  and the `repos` directory is always ignored, so repo files are never
  indexed.
* repos.yaml is the source of truth (import, dump, backup restore).
* /api/repos: list, create, get, edit, delete (files kept unless
  delete_files), update now, and a per-directory file tree.  Tagging uses
  the collections tag endpoint.

Also:
* run_command/process_runner can merge stderr into stdout (git writes
  progress to stderr).
* move_collection deletes an old directory that only holds empty
  directories (the move left an emptied .git behind).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:09 -06:00