Compare commits

...
11 Commits
Author SHA1 Message Date
Raito Bezarius e3ac0d9c19 release: merge release 2.94.2 back to mainline
This merge commit returns to the previous state prior to the release but leaves the tag in the branch history.
Release created with releng/create_release.xsh

Change-Id: I01c4b3caeaf2c180d69b406b0dbd663ed1e4392d
2026-05-04 19:02:23 +02:00
Raito Bezarius c8a447db88 release: 2.94.2 "Açaí na tigela"
Release produced with releng/create_release.xsh

Change-Id: I7cfa601006515be2710bc01111c6adc34ee5c048
2026-05-04 19:02:22 +02:00
Raito Bezarius ec912eda76 release: release notes for 2.94.2
Release created with releng/create_release.xsh

Change-Id: I117f10a79a674d262446555206e4976bb330f491
2026-05-04 19:02:20 +02:00
eldritch horrorsandRaito Bezarius 667b40f1ff libutil: fix nar parser buffer overflow
string data shares a buffer with the binary string length field. size
calculations for string read buffers always include the length field;
sufficiently large length fields can cause these calculations to wrap.
a malicious nar could use this for OOB writes in the daemon (as root).

since we use strings only as tags for archive members and for symlinks
with their OS-dependent length limits we can simply limit string size.
1 MiB should be sufficient for all symlinks, and tags are always tiny.

Change-Id: I89fb05f73c1dbeda45d91244aba4cd526a3d83e1
2026-05-04 19:01:44 +02:00
Raito Bezarius 64599c010c releng/keys: update the way to receive the ephemeral key
I don't understand how `ssh -l lix-releng` is supposed to work if it
doesn't say which host to target.

Change-Id: I791f3f3f49ecd5884c9e86b5d3b617fc139e031f
Signed-off-by: Raito Bezarius <raito@lix.systems>
2026-05-04 19:01:44 +02:00
Raito Bezarius 1953c4ae4b releng/environment: update staging parameters
These parameters are now created on https://s3.afnix.fr.

Change-Id: I96b6fd913429ee46d04c412cb141edd288665ced
Signed-off-by: Raito Bezarius <raito@lix.systems>
2026-05-04 19:01:44 +02:00
YurekaandRaito Bezarius 7e56afa0b1 releng: Adapt for AFNix S3
Change-Id: I29dbd62dcc70595ba3f2ac2a466a5c26a28aea99
(cherry picked from commit 0c63036c7d)
2026-05-04 19:01:44 +02:00
Florian KlinkandRaito Bezarius 5d6134064e libcstore: Fix null deref in writeDebugInfo for non-directory NARs
When index-debug-info is enabled and the store path being copied is a
regular file (not a directory), std::get_if<nar_index::Directory>
returns nullptr since the NAR root is a File variant. The loop then
immediately dereferences buildIdDir->contents on the null pointer,
causing a segfault.

Add a null check at the top of the loop to break early when the NAR
root is not a directory.

Change-Id: I3a6e792b84cc12c837ecaddf4fee889e1bcb6397
(cherry picked from commit 6c7ccc2588)
2026-05-04 16:33:04 +00:00
sterni 1d36c544be libcmd: add support for lowdown >= 3.0.0
lowdown 3.0.0 merged some flags into one to save on bits and did not add
any aliases for backward compatibility.

As with the changes for lowdown >= 1.4, we define a preprocessor flag to
gate the changes on and add a job to CI to ensure that lowdown < 3.0
keeps working (which is used by NixOS 25.11).

The channel version this Lix branch uses doesn't have bmake with support
for all tested platforms, so we can't properly build lowdown 3.0 (which
depends on it) for tests in CI. Given this is a relatively simple
backport tested on other Lix branches, it's probably fine.

Change-Id: I20a3e2fdaa05906f032ff66911c42867557fdd11
(cherry picked from commit e839708839aa132c8694abfdf83409a619f72c5a)
2026-04-10 16:04:52 +02:00
Jade Lovelace 7b210490aa version.json: 2.94.2, if we do backport more stuff
Change-Id: Ide1ce2a59a80a524d62c77681c2ea45e859a77e2
2026-03-13 12:17:56 -07:00
Jade Lovelace fba0fa5767 release: merge release 2.94.1 back to mainline
This merge commit returns to the previous state prior to the release but leaves the tag in the branch history.
Release created with releng/create_release.xsh

Change-Id: I2c10392b0b80bff519501c8018ed51aa6a151ca1
2026-03-13 09:39:07 -07:00
12 changed files with 181 additions and 129 deletions
+6
View File
@@ -73,6 +73,9 @@ detroyejr:
display_name: Jonathan De Troye
github: detroyejr
edef:
github: edef1c
edolstra:
display_name: Eelco Dolstra
github: edolstra
@@ -229,6 +232,9 @@ roberth:
display_name: Robert Hensing
github: roberth
sandydoo:
github: sandydoo
seppel3210:
github: Seppel3210
+31
View File
@@ -1,4 +1,35 @@
# Lix 2.94 "Açaí na tigela" (2025-11-17)
# Lix 2.94.2 (2026-05-04)
## Fixes
- Fix unsigned overflow leading to out-of-band write in the NAR parser [cl/5553](https://gerrit.lix.systems/c/lix/+/5553)
The NAR parser contained an unsigned integer overflow that could be used by an
attacker to write arbitrary data to an unknown memory location and possibly
achieve code execution. A successful attack on the system-wide Lix daemon
could lead to privilege escalation to root. Any process that involves NAR
serialization could trigger this issue, including (but not limited to)
- local user interaction, whether the users are trusted or untrusted
- malicious substituters sending malformed NARs
- remote builders sending malformed build results
- remote daemons sending malformed inputs when requesting remote builds
Successful attacks using this bug require ASLR weakening of some sort, whether
by architecture constraints (e.g. on 32 bit systems, where little randomization
is possible) or system configuration (e.g. low ASLR entropy when loading
libraries), and millions of attempts. Local attacks can be mounted in less than
an hour. Remote builds typically require a fresh SSH connection for each build
and are thus less susceptible. Only one attempt can be made by substituters for
every build using substituters, they are thus not a likely vector for attacks.
At the time of writing, MITRE has not assigned this a CVE yet.
Many thanks to [eldritch horrors](https://git.lix.systems/pennae), [Raito Bezarius](https://git.lix.systems/raito), [edef](https://github.com/edef1c), and [sandydoo](https://github.com/sandydoo) for this.
# Lix 2.94.1 (2026-03-13)
+4
View File
@@ -74,7 +74,11 @@ std::string renderMarkdownToTerminal(std::string_view markdown, StandardOutputSt
.vmargin = 0,
#endif /* LOWDOWN_SEPARATE_TERM_OPTS */
.feat = LOWDOWN_COMMONMARK | LOWDOWN_FENCED | LOWDOWN_DEFLIST | LOWDOWN_TABLES,
#ifdef LOWDOWN_CONSOLIDATED_OFLAGS
.oflags = LOWDOWN_NOLINK,
#else
.oflags = LOWDOWN_TERM_NOLINK,
#endif /* LOWDOWN_CONSOLIDATED_OFLAGS */
};
if (!shouldANSI(fileno)) {
opts.oflags |= LOWDOWN_TERM_NOANSI;
+1
View File
@@ -225,6 +225,7 @@ try {
auto * buildIdDir = std::get_if<nar_index::Directory>(&narIndex);
for (auto subdir : { "lib", "debug", ".build-id" }) {
if (!buildIdDir) break;
// get returns nullptr subdir does not exist, and std::get_if propagates it.
buildIdDir = std::get_if<nar_index::Directory>(get(buildIdDir->contents, subdir));
}
+1 -1
View File
@@ -384,7 +384,7 @@ struct Parser
buffer.clear(); \
std::move(str); \
})
#define READ_STRING() READ_STRING_LIMITED(std::numeric_limits<size_t>::max())
#define READ_STRING() READ_STRING_LIMITED(1048576)
#define READ_PADDING(size) \
do { \
if ((size) % 8) { \
+5
View File
@@ -350,6 +350,11 @@ if lowdown.version().version_compare('>= 1.4.0')
add_project_arguments('-DLOWDOWN_SEPARATE_TERM_OPTS', language: 'cpp')
endif
# TODO(sterni): drop the corresponding #ifdef after NixOS 25.11 is EOL which still distributes lowdown < 3.0.0
if lowdown.version().version_compare('>= 3.0.0')
add_project_arguments('-DLOWDOWN_CONSOLIDATED_OFLAGS', language: 'cpp')
endif
# HACK(Qyriad): rapidcheck's pkg-config doesn't include the libs lol
# Note: technically we 'check' for rapidcheck twice, for the internal-api-docs handling above,
# but Meson will cache the result of the first one, and the required : arguments are different.
+1 -1
View File
@@ -37,7 +37,7 @@ def setup_creds(env: RelengEnvironment):
key = keys.get_ephemeral_key(env)
$AWS_SECRET_ACCESS_KEY = key.secret_key
$AWS_ACCESS_KEY_ID = key.id
$AWS_DEFAULT_REGION = 'garage'
$AWS_DEFAULT_REGION = env.s3_region
$AWS_ENDPOINT_URL = env.s3_endpoint
+20 -11
View File
@@ -5,11 +5,12 @@ import functools
import subprocess
import dataclasses
S3_HOST = 's3.lix.systems'
S3_HOST = 's3-admin.afnix.fr'
S3_USER = 'lix-releng'
DEFAULT_STORE_URI_BITS = {
'region': 'garage',
'endpoint': 's3.lix.systems',
'region': 'global',
'endpoint': 's3.afnix.fr',
'want-mass-query': 'true',
'write-nar-listing': 'true',
'ls-compression': 'zstd',
@@ -54,7 +55,9 @@ class RelengEnvironment:
git_repo: Callable[[], str]
git_repo_is_gerrit: bool
s3_endpoint: str
s3_region: str
s3_ssh_host: str | None
s3_ssh_user: str | None
docker_targets: list[DockerTarget]
@@ -86,17 +89,19 @@ LOCAL = RelengEnvironment(
git_repo_is_gerrit=False,
docker_targets=[],
s3_endpoint = 'http://localhost:3900',
s3_region = 'garage',
s3_ssh_host = None,
s3_ssh_user = None,
)
STAGING = RelengEnvironment(
name='staging',
colour=functools.partial(sgr, GREEN),
docs_bucket='s3://staging-docs',
cache_bucket='s3://staging-cache',
docs_bucket='s3://docs.staging.lix.systems',
cache_bucket='s3://cache.staging.lix.systems',
cache_store_overlay={'secret-key': 'staging.key'},
releases_bucket='s3://staging-releases',
releases_bucket='s3://releases.staging.lix.systems',
git_repo=lambda: 'ssh://git@git.lix.systems/lix-project/lix-releng-staging',
git_repo_is_gerrit=False,
docker_targets=[
@@ -106,8 +111,10 @@ STAGING = RelengEnvironment(
DockerTarget('ghcr.io/lix-project/lix-releng-staging',
tags=['{version}', '{major}']),
],
s3_endpoint = 'https://s3.lix.systems',
s3_endpoint = 'https://s3.afnix.fr',
s3_region = 'garage',
s3_ssh_host = S3_HOST,
s3_ssh_user = S3_USER,
)
GERRIT_REMOTE_RE = re.compile(r'^ssh://(\w+@)?gerrit.lix.systems:2022/lix$')
@@ -127,13 +134,13 @@ def guess_gerrit_remote():
PROD = RelengEnvironment(
name='production',
colour=functools.partial(sgr, RED),
docs_bucket='s3://docs',
cache_bucket='s3://cache',
docs_bucket='s3://docs.lix.systems',
cache_bucket='s3://cache.lix.systems',
# FIXME: we should decrypt this with age into a tempdir in the future, but
# the issue is how to deal with the recipients file. For now, we should
# just delete it after doing a release.
cache_store_overlay={'secret-key': 'prod.key'},
releases_bucket='s3://releases',
releases_bucket='s3://releases.lix.systems',
git_repo=guess_gerrit_remote,
git_repo_is_gerrit=True,
docker_targets=[
@@ -142,8 +149,10 @@ PROD = RelengEnvironment(
tags=['{version}', '{major}']),
DockerTarget('ghcr.io/lix-project/lix', tags=['{version}', '{major}']),
],
s3_endpoint = 'https://s3.lix.systems',
s3_endpoint = 'https://s3.afnix.fr',
s3_region = 'global',
s3_ssh_host = S3_HOST,
s3_ssh_user = S3_USER,
)
ENVIRONMENTS = {
@@ -1,12 +1,12 @@
# SPDX-FileCopyrightText: 2024 Jade Lovelace
# SPDX-FileCopyrightText: 2026 Yureka Lilian <yureka@cyberchaos.dev>
# SPDX-License-Identifier: MIT
import argparse
import json
import sys
import datetime
import dataclasses
import re
from typing import Any, Literal, Optional
from typing import Any
import requests
import os
import logging
@@ -14,27 +14,34 @@ import logging
log = logging.getLogger(__name__)
log.setLevel(logging.INFO)
fmt = logging.Formatter('{asctime} {levelname} {name}: {message}',
datefmt='%b %d %H:%M:%S',
style='{')
fmt = logging.Formatter(
"{asctime} {levelname} {name}: {message}",
datefmt="%b %d %H:%M:%S",
style="{",
)
if not any(isinstance(h, logging.StreamHandler) for h in log.handlers):
hand = logging.StreamHandler()
hand.setFormatter(fmt)
log.addHandler(hand)
API_BASE = os.environ.get('GARAGE_ADMIN_API_BASE', 'http://localhost:3903')
API_KEY = os.environ['GARAGE_ADMIN_TOKEN']
API_BASE = os.environ.get("GARAGE_ADMIN_API_BASE", "http://localhost:3903")
API_KEY = os.environ["GARAGE_ADMIN_TOKEN"]
BUCKET_REGEX_STR = os.environ.get("BUCKET_REGEX", ".*")
BUCKET_REGEX = re.compile(BUCKET_REGEX_STR)
def api(method, endpoint: str, resp_json=True, **kwargs) -> Any:
log.info('http %s %s', method, endpoint)
if not endpoint.startswith('https'):
log.info("http %s %s", method, endpoint)
if not endpoint.startswith("https"):
endpoint = API_BASE + endpoint
resp = requests.request(method,
resp = requests.request(
method,
endpoint,
headers={'Authorization': f'Bearer {API_KEY}'},
**kwargs)
headers={"Authorization": f"Bearer {API_KEY}"},
**kwargs,
)
resp.raise_for_status()
if resp_json:
return resp.json()
@@ -42,97 +49,64 @@ def api(method, endpoint: str, resp_json=True, **kwargs) -> Any:
return resp
@dataclasses.dataclass
class Key:
name: str
id: str
secret_key: Optional[str] = None
def get_bucket_id(bucket_name: str) -> str:
resp: dict = api(
"GET", "/v2/GetBucketInfo", params={"globalAlias": bucket_name}
)
return resp["id"]
@dataclasses.dataclass
class Bucket:
id: str
def keys() -> list[Key]:
data: list[dict] = api('GET', '/v1/key?list')
return [Key(name=k['name'], id=k['id']) for k in data]
def delete_key(key: Key):
api('DELETE', '/v1/key', resp_json=False, params={'id': key.id})
def create_key(name: str) -> Key:
resp: dict = api('POST', '/v1/key', json={'name': name})
return Key(name=resp['name'],
id=resp['accessKeyId'],
secret_key=resp['secretAccessKey'])
AccessType = Literal['read'] | Literal['write'] | Literal['owner']
def get_bucket(bucket_name: str) -> Bucket:
resp: dict = api('GET', '/v1/bucket', params={'globalAlias': bucket_name})
return Bucket(resp['id'])
def grant(bucket: Bucket, access_types: list[AccessType], key: Key):
access_types_dict = {k: True for k in access_types}
api('POST',
'/v1/bucket/allow',
json={
'bucketId': bucket.id,
'accessKeyId': key.id,
'permissions': access_types_dict,
})
KEY_RE = re.compile(r'^.*ephemeral-(\d{14})$')
DATEFMT = '%Y%m%d%H%M%S'
def expired_keys(older_than: datetime.datetime) -> list[Key]:
ret = []
for key in keys():
if m := KEY_RE.match(key.name):
date = datetime.datetime.strptime(m.group(1), DATEFMT)
date = date.astimezone(datetime.UTC)
print(date)
if date < older_than:
ret.append(key)
return ret
DATEFMT = "%Y%m%d%H%M%S"
def do_new(args):
buckets = [get_bucket(b) for b in args.buckets]
for b in args.buckets:
if not BUCKET_REGEX.match(b):
print(f"Bucket {b} not in allowed buckeds '{BUCKET_REGEX_STR}'")
exit(1)
bucket_ids = [get_bucket_id(b) for b in args.buckets]
def optional(s: str, whether) -> list[str]:
if whether:
return [s]
else:
return []
key_name = args.name + "-" if args.name else ""
expiration = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(
seconds=args.age_secs
)
key_name += "ephemeral-" + expiration.strftime(DATEFMT)
access_types: list[AccessType] = optional('read', args.read) + optional(
'write', args.write) + optional('owner', args.owner) # type: ignore
key_resp: dict = api(
"POST",
"/v2/CreateKey",
json={
"name": key_name,
"expiration": expiration.isoformat(),
"neverExpires": False,
},
)
key_name = args.name + '-' if args.name else ''
key_name += "ephemeral-" + (
datetime.datetime.now(tz=datetime.UTC) +
datetime.timedelta(seconds=args.age_secs)).strftime(DATEFMT)
for b in bucket_ids:
api(
"POST",
"/v2/AllowBucketKey",
json={
"accessKeyId": key_resp["accessKeyId"],
"bucketId": b,
"permissions": {
"read": args.read,
"write": args.write,
"owner": args.owner,
},
},
)
k = create_key(key_name)
for b in buckets:
grant(b, access_types, k)
print(json.dumps(dataclasses.asdict(k), indent=2))
def do_clean(args):
older_than = datetime.datetime.now(tz=datetime.UTC)
for key in expired_keys(older_than):
delete_key(key)
print(
json.dumps(
{
"name": key_resp["name"],
"id": key_resp["accessKeyId"],
"secret_key": key_resp["secretAccessKey"],
},
indent=2,
)
)
def main():
@@ -148,28 +122,27 @@ def main():
new = sps.add_parser("new", help="Make an ephemeral key")
new.add_argument("--name", help="Name prefix for the key")
new.add_argument("--read",
action="store_true",
help="Grant read access to buckets")
new.add_argument("--write",
action="store_true",
help="Grant write access to buckets")
new.add_argument("--owner",
action="store_true",
help="Grant owner access to buckets")
new.add_argument("--age-secs",
new.add_argument(
"--read", action="store_true", help="Grant read access to buckets"
)
new.add_argument(
"--write", action="store_true", help="Grant write access to buckets"
)
new.add_argument(
"--owner", action="store_true", help="Grant owner access to buckets"
)
new.add_argument(
"--age-secs",
type=int,
required=True,
help="Maximum key lifetime in seconds")
new.add_argument("buckets", nargs='*', help="Buckets to grant access to")
help="Maximum key lifetime in seconds",
)
new.add_argument("buckets", nargs="*", help="Buckets to grant access to")
new.set_defaults(cmd=do_new)
clean = sps.add_parser("clean", help="Clean up old keys")
clean.set_defaults(cmd=do_clean)
args = ap.parse_args()
args.cmd(args)
if __name__ == '__main__':
if __name__ == "__main__":
main()
+1 -1
View File
@@ -14,7 +14,7 @@ def get_ephemeral_key(
env.docs_bucket.removeprefix('s3://'),
]
if env.s3_ssh_host is not None:
command = ['ssh', '-l', 'root', env.s3_ssh_host, *command]
command = ['ssh', f'{env.s3_ssh_user}@{env.s3_ssh_host}', *command]
output = subprocess.check_output(command)
d = json.loads(output.decode())
return environment.S3Credentials(name=d['name'],
+23
View File
@@ -521,4 +521,27 @@ INSTANTIATE_TEST_SUITE_P(
concat({header, make_directory({{"DE", make_file(false, "meow")}, {"de", make_file(false, "mrrp")}})})
))
);
TEST_F(NarTest, stringSizeLimit)
{
GeneratorSource source([]() -> Generator<Bytes> {
const char preamble[] =
"\x0d\x00\x00\x00\x00\x00\x00\x00nix-archive-1\x00\x00\x00"
"\x01\x00\x00\x00\x00\x00\x00\x00(\x00\x00\x00\x00\x00\x00\x00"
"\x04\x00\x00\x00\x00\x00\x00\x00type\x00\x00\x00\x00";
co_yield Bytes{preamble, sizeof(preamble) - 1};
// the nar parser keeps all strings in a buffer with the 8 byte length prefix in front.
// sufficiently large strings overflowed caused the buffer size calculation to overflow
// and thus allowed out-of-bounds writes in the daemon and potentially privesc to root.
co_yield Bytes{"\xf7\xff\xff\xff\xff\xff\xff\xff", 8};
// overflow would happen while reading data
while (true) {
co_yield Bytes{"foo-", 4};
}
}());
auto parser = nar::parse(source);
ASSERT_THROW(parser.next(), SerialisationError);
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "2.94.1",
"official_release": true,
"version": "2.94.2",
"official_release": false,
"release_name": "Açaí na tigela"
}