fix: harden setup against plain http, untrusted output and unsafe values
Require consent before sending the token over plain http to a non-loopback host, strip control characters from server-supplied text, restrict env values and tokens to printable ASCII, validate the URLs like other env values and fsync the directory after the atomic replace.
This commit is contained in:
1 parent
6c9b2b74ca
commit
517c675048
2 files changed
+198
-11
No files matched your search
@@ -18,6 +18,7 @@ import ssl
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -45,7 +46,23 @@ PLACEHOLDER_GITEA_HOSTS = frozenset({"gitea.example.com"})
|
||||
REQUIRED_SCOPES = tuple(f"write:{category}" for category in GITEA_SCOPE_CATEGORIES)
|
||||
|
||||
_ASSIGNMENT = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$")
|
||||
_SAFE_VALUE = re.compile(r"^[^\s\"'#$\\]+$")
|
||||
#: Printable ASCII without whitespace and without the characters a dotenv parser or
|
||||
#: a shell would treat specially. Anything else is refused instead of being quoted.
|
||||
_SAFE_VALUE = re.compile(r"^(?!.*[\"'#$\\])[!-~]+$")
|
||||
|
||||
|
||||
def printable(text: str, limit: int = 200) -> str:
|
||||
"""Return ``text`` made safe to print: control and format characters become spaces.
|
||||
|
||||
Text that comes from the Gitea server is untrusted. Control characters (including
|
||||
escape sequences' ESC byte, C1 controls, bidi overrides and line separators) must not
|
||||
reach the operator's terminal.
|
||||
"""
|
||||
cleaned = "".join(
|
||||
" " if unicodedata.category(char)[0] == "C" or char in "\u2028\u2029" else char
|
||||
for char in text
|
||||
)
|
||||
return cleaned.strip()[:limit]
|
||||
|
||||
|
||||
class SetupError(Exception):
|
||||
@@ -162,6 +179,21 @@ def write_env_atomic(path: Path, content: str) -> None:
|
||||
except BaseException:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
_fsync_directory(path.parent)
|
||||
|
||||
|
||||
def _fsync_directory(directory: Path) -> None:
|
||||
"""Flush the directory entry of a just-renamed file (best effort where unsupported)."""
|
||||
try:
|
||||
fd = os.open(directory, os.O_RDONLY)
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(fd)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -236,7 +268,7 @@ class GiteaApi:
|
||||
message = body["message"]
|
||||
except ValueError:
|
||||
message = ""
|
||||
message = re.sub(r"[\x00-\x1f\x7f]", " ", self.redact(message)).strip()[:200]
|
||||
message = printable(self.redact(message))
|
||||
return f"HTTP {response.status_code}" + (f": {message}" if message else "")
|
||||
|
||||
def json_object(self, response: httpx.Response, what: str) -> dict[str, Any]:
|
||||
@@ -311,9 +343,10 @@ def _say(message: str = "") -> None:
|
||||
|
||||
def _validate_gitea_url(value: str) -> str:
|
||||
try:
|
||||
return Settings._validate_gitea_url(value)
|
||||
validated = Settings._validate_gitea_url(value)
|
||||
except ValueError as exc:
|
||||
raise SetupError(f"invalid Gitea URL: {exc}") from None
|
||||
return _check_value_text("the Gitea URL", validated)
|
||||
|
||||
|
||||
def _validate_public_url(value: str) -> str:
|
||||
@@ -322,7 +355,7 @@ def _validate_public_url(value: str) -> str:
|
||||
except ValueError as exc:
|
||||
raise SetupError(f"invalid public base URL: {exc}") from None
|
||||
assert validated is not None # noqa: S101 (value is a non-empty string here)
|
||||
return validated
|
||||
return _check_value_text("the public base URL", validated)
|
||||
|
||||
|
||||
def _validate_token_text(token: str) -> str:
|
||||
@@ -331,15 +364,18 @@ def _validate_token_text(token: str) -> str:
|
||||
raise SetupError("no Gitea token given")
|
||||
if not _SAFE_VALUE.match(token):
|
||||
raise SetupError(
|
||||
"the Gitea token contains whitespace or one of the characters "
|
||||
"\" ' # $ \\ that cannot be stored safely in an env file"
|
||||
"the Gitea token must be printable ASCII without whitespace and without any of "
|
||||
"the characters \" ' # $ \\ (it cannot be stored safely in an env file)"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def _check_value_text(name: str, value: str) -> str:
|
||||
if not _SAFE_VALUE.match(value):
|
||||
raise SetupError(f"{name} contains characters that cannot be stored safely in an env file")
|
||||
raise SetupError(
|
||||
f"{name} contains characters that cannot be stored safely in an env file "
|
||||
"(only printable ASCII without whitespace or any of \" ' # $ \\ is allowed)"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@@ -390,7 +426,9 @@ def verify_instance(api: GiteaApi, report: Report) -> None:
|
||||
version = api.json_object(response, "GET /api/v1/version").get("version")
|
||||
if not isinstance(version, str) or not version:
|
||||
raise SetupError("GET /api/v1/version did not report a Gitea version")
|
||||
report.version = version[:64]
|
||||
report.version = printable(version, 64)
|
||||
if not report.version:
|
||||
raise SetupError("GET /api/v1/version did not report a printable Gitea version")
|
||||
_say(f"Gitea reachable: version {report.version}")
|
||||
|
||||
response = api.request("GET", "/user")
|
||||
@@ -403,7 +441,7 @@ def verify_instance(api: GiteaApi, report: Report) -> None:
|
||||
raise SetupError(f"GET /api/v1/user failed ({api.detail(response)})")
|
||||
user = api.json_object(response, "GET /api/v1/user")
|
||||
login = user.get("login")
|
||||
report.login = login if isinstance(login, str) else "?"
|
||||
report.login = (printable(login, 64) or "?") if isinstance(login, str) else "?"
|
||||
if user.get("is_admin") is not True:
|
||||
raise SetupError(
|
||||
f"the token belongs to '{report.login}', who is not a site administrator. "
|
||||
@@ -767,10 +805,9 @@ def _run(args: argparse.Namespace) -> int:
|
||||
|
||||
report = Report()
|
||||
report.gitea_url = _resolve_gitea_url(args, existing)
|
||||
_confirm_plain_http(report.gitea_url, yes=args.yes)
|
||||
token = _resolve_token(args, existing)
|
||||
public_url = _resolve_public_url(args, existing)
|
||||
if urlsplit(report.gitea_url).scheme == "http" and not _is_loopback(report.gitea_url):
|
||||
report.warnings.append("the Gitea URL uses plain http, so the token travels unencrypted.")
|
||||
|
||||
oauth_wanted = not args.no_oauth and public_url is not None
|
||||
_say(f"Using {report.gitea_url}")
|
||||
@@ -837,6 +874,25 @@ def _run(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _confirm_plain_http(url: str, *, yes: bool) -> None:
|
||||
"""Warn about a plain http Gitea URL on a non-loopback host and require consent.
|
||||
|
||||
Runs before the first request, so the token never travels unencrypted unless the
|
||||
operator agreed with ``--yes`` or an interactive answer.
|
||||
"""
|
||||
if urlsplit(url).scheme != "http" or _is_loopback(url):
|
||||
return
|
||||
_say(
|
||||
f"Warning: {url} uses plain http. The Gitea admin token and the OAuth client secret "
|
||||
"would travel unencrypted over the network."
|
||||
)
|
||||
if not _confirm("Send the token over plain http anyway?", yes=yes):
|
||||
raise SetupError(
|
||||
"refusing to send the token over plain http to a non-loopback host. "
|
||||
"Use an https URL, or re-run with --yes to accept the risk."
|
||||
)
|
||||
|
||||
|
||||
def _is_loopback(url: str) -> bool:
|
||||
return is_loopback_host(urlsplit(url).hostname or "")
|
||||
|
||||
|
||||
Reference in new issue
Block a user