fix(app)!: instances over plain HTTP are refused, but on this machine

What the app sends an instance (the proofs that sign in, the keys'
wrappings, every request a session carries) must not be read or changed on
the way, so an instance is reached over HTTPS, or over plain HTTP only on
this machine (localhost, 127.0.0.0/8, ::1), as Jellyfin servers are. An
address over plain HTTP elsewhere is refused before anything is sent there.
One kept from before is never opened: the app says why, and offers another
instance or removing it from the device, never dropping it silently.
Development builds still use their own instance over HTTP (an emulator
reaches the developer's machine as 10.0.2.2); releases never.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 14:35:21 +02:00
commit 4914df348e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 338 additions and 2 deletions

View file

@ -197,6 +197,58 @@ void main() {
expect(opened, [other]);
});
testWidgets('an instance over plain HTTP is refused before anything is '
'sent there', (tester) async {
await start(tester, answering: {other});
await tester.tap(find.text('Another instance'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'http://learn.example.org');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(
find.textContaining('reaches instances over HTTPS only'),
findsOneWidget,
);
expect(opened, isEmpty);
});
testWidgets('an instance kept from before over plain HTTP is never opened: '
'another is used, or it is removed', (tester) async {
final insecure = Instance.parse('http://learn.example.net')!;
store = MemoryInstanceStore((
current: insecure,
known: [builtIn, insecure],
));
await start(tester, answering: {builtIn});
expect(opened, isEmpty, reason: 'nothing sent over plain HTTP');
expect(find.textContaining('learn.example.net is reached over'), findsOne);
await tester.tap(find.text('Use another instance'));
await tester.pumpAndSettle();
expect(opened, [builtIn]);
expect(store.kept?.known, [builtIn, insecure], reason: 'kept, unused');
expect(forgotten, isEmpty);
});
testWidgets('an instance kept from before over plain HTTP can be removed '
'from the device', (tester) async {
final insecure = Instance.parse('http://learn.example.net')!;
store = MemoryInstanceStore((current: insecure, known: [insecure]));
await start(tester, answering: {builtIn});
await tester.tap(find.text('Remove from this device'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
await tester.pumpAndSettle();
expect(forgotten, [insecure]);
expect(opened, [builtIn]);
expect(store.kept?.known, [builtIn]);
});
testWidgets('an install from before goes on without asking', (tester) async {
await start(tester, usedBefore: true);

View file

@ -83,4 +83,69 @@ void main() {
expect(changes, 4);
});
test('allows an instance reached securely, and over plain HTTP elsewhere '
"only the build's own in a development build", () {
final insecure = Instance.parse('http://10.0.2.2:8080')!;
// A development build, as tests run.
final development = InstanceChoice(
store,
builtIn: insecure,
forget: (instance) async {},
);
expect(development.allows(builtIn), isTrue);
expect(
development.allows(Instance.parse('http://localhost:8080')!),
isTrue,
);
expect(development.allows(insecure), isTrue);
expect(
development.allows(Instance.parse('http://learn.example.org')!),
isFalse,
);
final release = InstanceChoice(
store,
builtIn: insecure,
forget: (instance) async {},
allowsInsecureBuiltIn: false,
);
expect(release.allows(insecure), isFalse);
});
test('leaves an instance it no longer allows for another, forgetting it '
'if asked', () async {
final insecure = Instance.parse('http://learn.example.net')!;
store = MemoryInstanceStore((
current: insecure,
known: [other, insecure, builtIn],
));
final instances = choice();
await instances.load(usedBefore: false);
expect(instances.current, insecure, reason: 'never dropped silently');
await instances.leave(insecure, forget: false);
expect(instances.current, other, reason: 'the first allowed one');
expect(instances.known, [other, insecure, builtIn]);
expect(forgotten, isEmpty);
await instances.use(insecure);
await instances.leave(insecure, forget: true);
expect(instances.current, other);
expect(instances.known, [other, builtIn]);
expect(forgotten, [insecure]);
expect(store.kept?.known, [other, builtIn]);
});
test("leaving the only one goes to the build's own", () async {
final insecure = Instance.parse('http://learn.example.net')!;
store = MemoryInstanceStore((current: insecure, known: [insecure]));
final instances = choice();
await instances.load(usedBefore: false);
await instances.leave(insecure, forget: true);
expect(instances.current, builtIn);
expect(instances.known, [builtIn]);
expect(forgotten, [insecure]);
});
}

View file

@ -48,4 +48,26 @@ void main() {
Instance.parse('https://LEARN.example.org/'),
);
});
test('is reached securely over HTTPS, or over HTTP on this machine only', () {
for (final address in [
'learn.example.org',
'https://learn.example.org:8443/learn',
'http://localhost:8080',
'http://127.0.0.1:8080',
'http://127.1.2.3',
'http://[::1]:8080',
]) {
expect(Instance.parse(address)!.isSecure, isTrue, reason: address);
}
for (final address in [
'http://learn.example.org',
'http://10.0.2.2:8080',
'http://192.168.1.10:8080',
'http://localhost.example.org',
'http://[::2]',
]) {
expect(Instance.parse(address)!.isSecure, isFalse, reason: address);
}
});
}