Skip to content

Commit 33030ac

Browse files
authored
chore: backport the 2.2.0 release-branch fixes to develop (#1006)
* fix(net): three plaintext-guard fixes for 2.2.0, and CI on release PRs (#1004) Carries the plaintext-guard fixes onto the release branch. The first two were raised in review of #988 and were never fixed there, because the work landed on a branch targeting `develop` (#1002, since merged) while `release/2.2.0` is cut from `main`. - **A redirect bypassed the guard entirely.** `send` validated the initial URI and then handed the request to a client that follows redirects by default, below `BaseClient.send` where the guard never sees the hop. An approved private endpoint answering 30x with a public `http://` target would have had that connection made. Automatic redirects are now off, so a 30x returns to the caller as the response it is — for `https://` too. - **The Host header dropped the port.** Rebinding `http://ollama.lan:11434` to its resolved address wrote `Host: ollama.lan`, a different authority from the one typed, and a local model server is essentially never on 80. - **A zoned link-local address was refused.** `Uri` escapes the zone id, so `fe80::1%wlan0` reaches the guard as `fe80::1%25wlan0`, which `InternetAddress.tryParse` rejects; the address fell through to a DNS lookup that cannot succeed and the caller was told the server is unreachable. `dart:io` performs the same unescaping itself before it connects, which is why an unguarded client worked. Fail-closed, and it predates 2.2.0. Found by audit, not by review. The review also objected that the guard's tests used a recording fake, which cannot follow a redirect, so they pinned that `followRedirects` was set false rather than that it stops the second hop. There is now a group running against a real `HttpServer` on loopback and a real `dart:io` client, over both branches through the guard, with a control test proving an unguarded client really does follow the 302. All three fixes are mutation-tested: reverting the redirect guard fails four tests, the port two, the zone two. Also adds `release/**` to the workflow's `pull_request` trigger. This PR opened with exactly one check — the Copilot reviewer — while reporting `CLEAN`, which is the silent failure the trigger list's own comment warns about, now hit on a release branch. Cherry-picked from #1002 and from #1005, which is closed in favour of this. `plaintext_destination_guard.dart` and its test file are byte-identical across both branches. (cherry picked from commit ae8ad7c) * fix(bulk-add): round a converted quantity to the precision the field takes The amount field accepts at most two decimals: `_quantityPattern` in bulk_add_screen.dart is both the submit check and the field's input formatter. A converted imperial quantity carries far more — `1 lb` is 453.59237 g — and the prefill passed it through untouched. So an ordinary imperial line was unloggable. `1 lb mince` prefilled "453.59237", the submit check refused it with a message naming only the field, and because that check validates the whole batch before writing anything, one such row blocked every correct row beside it. Recovery was worse: the pattern is anchored, so the formatter matched nothing on the non-conforming string and the field blanked entirely on the first keystroke. Rounding at the prefill is what keeps the two in step. The floor is the same concern from the other end — a positive quantity below 0.005 would round to "0.00", which the check rejects for being non-positive, and a backend serving weight can be that small. `lb` is a deliberate unit, not a stray one: it is in the model tool schema enum because the model was otherwise mapping "1 pound of mince" onto `oz`, so the model path reached this too. (cherry picked from commit fcb2323)
1 parent e44c9b8 commit 33030ac

5 files changed

Lines changed: 209 additions & 6 deletions

File tree

.github/workflows/default_workflow.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ on:
3232
# to add it back, and the failure mode is silent — a retargeted PR
3333
# still reports mergeable with no checks run.
3434
- 'feature/**'
35+
# Release branches, for the same reason and with the same failure mode.
36+
# `release/2.2.0` is cut from `main`, so a fix that landed on `develop`
37+
# is not in it, and the PR carrying that fix across got exactly one
38+
# check — the Copilot reviewer — while reporting `CLEAN`. A release
39+
# branch is the last place that should merge unverified: everything on
40+
# it is by definition about to become `main`.
41+
- 'release/**'
3542
workflow_dispatch:
3643

3744
jobs:

lib/core/utils/plaintext_destination_guard.dart

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,32 @@ class PlaintextDestinationGuard {
8888
static Future<List<InternetAddress>> _resolve(String host) =>
8989
InternetAddress.lookup(host, type: InternetAddressType.any);
9090

91+
/// Turns the `%25` a URI must spell a zone id with back into the `%` that
92+
/// [InternetAddress] parses.
93+
///
94+
/// A link-local address needs a zone to be routable on a host with more
95+
/// than one interface, and RFC 6874 says a URI escapes it: `Uri` stores
96+
/// `http://[fe80::1%wlan0]` with a host of `fe80::1%25wlan0` — typing the
97+
/// bare `%` gets you the same thing, so this is not a corner someone has to
98+
/// know the RFC to reach. `InternetAddress.tryParse` returns null on that
99+
/// escaped form, which sent the address down the DNS branch below, where
100+
/// the lookup fails and the caller is told the server is **unreachable** —
101+
/// about a link-local server this check exists to *permit*, and which an
102+
/// unguarded client reaches perfectly well.
103+
///
104+
/// `dart:io` does exactly this substitution itself, in
105+
/// `escapeLinkLocalAddress`, before it resolves or connects. Doing it here
106+
/// too is what makes the check agree with the socket layer it is guarding.
107+
///
108+
/// Anything that still fails to parse falls through to the lookup unchanged,
109+
/// so a name is never touched: the `25` is only dropped when it directly
110+
/// follows the first `%`.
111+
static String _withZoneUnescaped(String host) {
112+
final marker = host.indexOf('%');
113+
if (marker < 0 || !host.startsWith('25', marker + 1)) return host;
114+
return host.replaceRange(marker + 1, marker + 3, '');
115+
}
116+
91117
/// Returns the URL to actually request, or throws
92118
/// [InsecureDestinationException].
93119
///
@@ -103,7 +129,7 @@ class PlaintextDestinationGuard {
103129
if (url.scheme != 'http') return url;
104130

105131
final host = url.host;
106-
final literal = InternetAddress.tryParse(host);
132+
final literal = InternetAddress.tryParse(_withZoneUnescaped(host));
107133
if (literal != null) {
108134
// No lookup to do, and nothing to pin — the user typed the address.
109135
if (isPrivateDestination(literal)) return url;

lib/features/add_meal/presentation/bloc/bulk_add_bloc.dart

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -435,11 +435,11 @@ class BulkAddBloc extends Bloc<BulkAddEvent, BulkAddState> {
435435
bool usesImperialUnits,
436436
) {
437437
final parsedQuantity = parsed.quantity;
438-
if (parsedQuantity != null) return _trimZeros(parsedQuantity);
438+
if (parsedQuantity != null) return _amountText(parsedQuantity);
439439

440440
if (meal != null && meal.hasServingValues) {
441441
final serving = meal.servingQuantity;
442-
if (serving != null) return _trimZeros(serving);
442+
if (serving != null) return _amountText(serving);
443443
}
444444
return usesImperialUnits ? '1' : '100';
445445
}
@@ -497,9 +497,26 @@ class BulkAddBloc extends Bloc<BulkAddEvent, BulkAddState> {
497497
return fallback;
498498
}
499499

500-
static String _trimZeros(double value) => value == value.roundToDouble()
501-
? value.toInt().toString()
502-
: value.toString();
500+
/// Formats a quantity for the amount field, which accepts at most two
501+
/// decimals — `_quantityPattern` on the bulk-add screen is both the submit
502+
/// check and the field's input formatter. A converted imperial quantity has
503+
/// many more: `1 lb` is 453.59237 g. Prefilling that verbatim produced a row
504+
/// the submit check refused, with a message naming only the field, and
505+
/// because that check aborts the batch, one such row blocked every correct
506+
/// row beside it. The pattern is anchored, so the formatter then rejected
507+
/// every edit too and the field blanked on the first keystroke. Rounding
508+
/// here is what keeps the prefill and the check in step.
509+
///
510+
/// The floor is the same concern from the other end: a positive quantity
511+
/// below 0.005 would round to zero, which the check rejects for being
512+
/// non-positive.
513+
static String _amountText(double value) {
514+
final rounded = double.parse(value.toStringAsFixed(2));
515+
final quantity = rounded <= 0 && value > 0 ? 0.01 : rounded;
516+
return quantity == quantity.roundToDouble()
517+
? quantity.toInt().toString()
518+
: quantity.toString();
519+
}
503520

504521
void _onChangeCandidate(
505522
ChangeRowCandidateEvent event,

test/unit_test/bulk_add_bloc_test.dart

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,78 @@ void main() {
221221
expect(state.rows.single.unit, 'g');
222222
});
223223

224+
// The amount field accepts at most two decimals, and the same anchored
225+
// pattern is both the submit check and the field's input formatter. A
226+
// prefill it refuses is a row that cannot be logged and cannot be edited
227+
// back into shape — the field blanks on the first keystroke — and the
228+
// submit check aborts the whole batch on it. Mirrored from
229+
// `_quantityPattern` in bulk_add_screen.dart; if that loosens, so can this.
230+
final fieldPattern = RegExp(r'^\d+([.,]\d{0,2})?$');
231+
232+
test(
233+
'a pound quantity is prefilled at a precision the field accepts',
234+
() async {
235+
final bloc = blocWith({
236+
'mince': [meal('Mince')],
237+
});
238+
239+
// 1 lb is 453.59237 g. Prefilled verbatim it was unloggable.
240+
final state = await parse(bloc, '1 lb mince');
241+
242+
expect(state.rows.single.amountText, '453.59');
243+
expect(state.rows.single.unit, 'g');
244+
expect(fieldPattern.hasMatch(state.rows.single.amountText), isTrue);
245+
},
246+
);
247+
248+
test('one refused row no longer blocks the rows beside it', () async {
249+
final bloc = blocWith({
250+
'mince': [meal('Mince')],
251+
'rice': [meal('Rice')],
252+
});
253+
254+
final state = await parse(bloc, '1 lb mince, 100 g rice');
255+
256+
expect(state.rows, hasLength(2));
257+
for (final row in state.rows) {
258+
expect(
259+
fieldPattern.hasMatch(row.amountText),
260+
isTrue,
261+
reason: '${row.meal?.name}: "${row.amountText}" fails the submit '
262+
'check, and that check refuses the entire batch',
263+
);
264+
}
265+
});
266+
267+
test('a serving weight carrying more decimals is rounded too', () async {
268+
final bloc = blocWith({
269+
'almonds': [
270+
meal('Almonds', servingQuantity: 226.796185, servingUnit: 'g'),
271+
],
272+
});
273+
274+
final state = await parse(bloc, 'almonds');
275+
276+
expect(state.rows.single.amountText, '226.8');
277+
expect(fieldPattern.hasMatch(state.rows.single.amountText), isTrue);
278+
});
279+
280+
test(
281+
'a positive quantity below the rounding floor does not become zero',
282+
() async {
283+
final bloc = blocWith({
284+
'saffron': [meal('Saffron', servingQuantity: 0.004, servingUnit: 'g')],
285+
});
286+
287+
final state = await parse(bloc, 'saffron');
288+
289+
// Rounding alone gives "0.00", which the submit check rejects for
290+
// being non-positive — the same dead end by the other route.
291+
expect(state.rows.single.amountText, '0.01');
292+
expect(fieldPattern.hasMatch(state.rows.single.amountText), isTrue);
293+
},
294+
);
295+
224296
test('changing candidates falls back from unsupported units', () async {
225297
final bloc = blocWith({
226298
'drink': [

test/unit_test/plaintext_destination_guard_test.dart

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,87 @@ void main() {
168168
InternetAddressType.IPv6);
169169
});
170170

171+
// A zone id is what makes a link-local address routable on a host with
172+
// more than one interface, and it is the case the guard was silently
173+
// breaking: `Uri` escapes the `%` to `%25`, `InternetAddress.tryParse`
174+
// refuses that, and the address fell through to a DNS lookup that cannot
175+
// succeed — so a link-local server this check exists to *permit* was
176+
// reported unreachable, while an unguarded client reached it fine.
177+
//
178+
// Two things make these tests fussier than they look:
179+
//
180+
// - `tryParse` resolves a **named** zone against the running machine's
181+
// interfaces, so a hardcoded `%eth0` is null on a host without an eth0.
182+
// The name is asked of the machine instead.
183+
// - A **numeric** zone tests nothing at all: `%1` escapes to `%251`,
184+
// which parses happily as scope 251, so the guard never stumbled. Only
185+
// a named zone reproduces the failure.
186+
//
187+
// There is deliberately no "zoned public address" case: `tryParse`
188+
// rejects a zone on anything that is not link-local (`2001:db8::1%lo` is
189+
// null), so such a URL was never reaching this branch to begin with.
190+
Future<String?> anInterfaceName() async {
191+
final interfaces = await NetworkInterface.list(
192+
includeLoopback: true,
193+
type: InternetAddressType.any,
194+
);
195+
return interfaces.isEmpty ? null : interfaces.first.name;
196+
}
197+
198+
/// Fails rather than resolving. A zoned literal must be settled by the
199+
/// literal branch; reaching the resolver *is* the bug.
200+
PlaintextDestinationGuard guardThatMustNotResolve() =>
201+
PlaintextDestinationGuard(
202+
lookup: (host) async => fail('a zoned literal reached the resolver: $host'),
203+
);
204+
205+
test('a zoned link-local literal is allowed, and left alone', () async {
206+
final zone = await anInterfaceName();
207+
if (zone == null) {
208+
markTestSkipped('no network interface to name a zone with');
209+
return;
210+
}
211+
final url = Uri.parse('http://[fe80::1%$zone]:11434/v1/chat/completions');
212+
// What the guard actually sees, and what `tryParse` answers to it.
213+
expect(url.host, 'fe80::1%25$zone');
214+
expect(InternetAddress.tryParse(url.host), isNull);
215+
216+
// Returned untouched: the socket layer does its own unescaping, and
217+
// rewriting the host here would only cost the zone.
218+
expect(await guardThatMustNotResolve().approve(url), url);
219+
});
220+
221+
test('the typed % and the RFC 6874 %25 are the same destination', () async {
222+
// Nobody needs to know the RFC to hit this — both spellings land on the
223+
// same escaped host, so the plain one cannot behave differently.
224+
final zone = await anInterfaceName();
225+
if (zone == null) {
226+
markTestSkipped('no network interface to name a zone with');
227+
return;
228+
}
229+
final typed = Uri.parse('http://[fe80::1%$zone]:11434/v1');
230+
final escaped = Uri.parse('http://[fe80::1%25$zone]:11434/v1');
231+
expect(typed.host, escaped.host);
232+
233+
final guard = guardThatMustNotResolve();
234+
expect(await guard.approve(typed), typed);
235+
expect(await guard.approve(escaped), escaped);
236+
});
237+
238+
test('a hostname is never rewritten by the zone handling', () async {
239+
// The `25` comes off only when it directly follows the first `%`, so a
240+
// name still reaches the resolver exactly as it was typed.
241+
final seen = <String>[];
242+
final guard = PlaintextDestinationGuard(lookup: (host) async {
243+
seen.add(host);
244+
return [_v4('192.168.1.5')];
245+
});
246+
247+
await guard.approve(Uri.parse('http://ollama.lan:11434/v1'));
248+
249+
expect(seen, ['ollama.lan']);
250+
});
251+
171252
test('a name that does not resolve is not a policy refusal', () async {
172253
// Telling someone their address is unsafe when it is merely
173254
// unreachable sends them to fix the wrong thing.

0 commit comments

Comments
 (0)