diff --git a/melos.yaml b/melos.yaml index 4aada92b7d8..8f0bdbf23c0 100644 --- a/melos.yaml +++ b/melos.yaml @@ -41,7 +41,11 @@ scripts: test: > melos run test:dart && melos run test:flutter - test:dart: melos exec --no-flutter --concurrency=1 --fail-fast --dir-exists=test -- flutter test --concurrency=$(nproc --all) --coverage + test:dart: > + melos exec --no-flutter --concurrency=1 --fail-fast --dir-exists=test -- " + dart test --concurrency=$(nproc --all) --coverage=coverage && + dart pub global run coverage:format_coverage --packages=.dart_tool/package_config.json --report-on=lib --lcov -o ./coverage/lcov.info -i ./coverage + " test:flutter: melos exec --flutter --concurrency=1 --fail-fast --dir-exists=test -- flutter test --concurrency=$(nproc --all) --coverage generate:neon:build_runner: melos exec --scope="neon*" --file-exists="build.yaml" -- dart run build_runner build --delete-conflicting-outputs && melos run format generate:neon:l10n: melos exec --flutter --dir-exists="lib/l10n" flutter gen-l10n && melos run format diff --git a/packages/neon_framework/lib/src/blocs/login_check_account.dart b/packages/neon_framework/lib/src/blocs/login_check_account.dart index 3e65436257d..32008870858 100644 --- a/packages/neon_framework/lib/src/blocs/login_check_account.dart +++ b/packages/neon_framework/lib/src/blocs/login_check_account.dart @@ -46,6 +46,7 @@ class _LoginCheckAccountBloc extends InteractiveBloc implements LoginCheckAccoun password: password, httpClient: NeonHttpClient( userAgent: neonUserAgent, + baseURL: serverURL, ), ); diff --git a/packages/neon_framework/lib/src/blocs/login_check_server_status.dart b/packages/neon_framework/lib/src/blocs/login_check_server_status.dart index e8df93e4531..19c74569499 100644 --- a/packages/neon_framework/lib/src/blocs/login_check_server_status.dart +++ b/packages/neon_framework/lib/src/blocs/login_check_server_status.dart @@ -37,6 +37,7 @@ class _LoginCheckServerStatusBloc extends InteractiveBloc implements LoginCheckS serverURL, httpClient: NeonHttpClient( userAgent: neonUserAgent, + baseURL: serverURL, ), ); diff --git a/packages/neon_framework/lib/src/blocs/login_flow.dart b/packages/neon_framework/lib/src/blocs/login_flow.dart index 6f547eab8c5..648e76b315e 100644 --- a/packages/neon_framework/lib/src/blocs/login_flow.dart +++ b/packages/neon_framework/lib/src/blocs/login_flow.dart @@ -42,6 +42,7 @@ class _LoginFlowBloc extends InteractiveBloc implements LoginFlowBloc { serverURL, httpClient: NeonHttpClient( userAgent: neonUserAgent, + baseURL: serverURL, ), ); final resultController = StreamController(); diff --git a/packages/neon_framework/lib/src/models/account.dart b/packages/neon_framework/lib/src/models/account.dart index 67eacd6e246..88542c715c0 100644 --- a/packages/neon_framework/lib/src/models/account.dart +++ b/packages/neon_framework/lib/src/models/account.dart @@ -79,6 +79,7 @@ abstract class Account implements Credentials, Findable, Built interceptRequest({required http.BaseRequest request}) async { + assert( + shouldInterceptRequest(request), + 'Request should not be intercepted.', + ); + + if (!_kIsWeb) { + return request..headers.remove(HttpHeaders.cookieHeader); + } + + if (token == null) { + _log.fine('Acquiring new CSRF token for WebDAV'); + + final response = await _client.get(Uri.parse('$_baseURL/index.php')); + if (response.statusCode >= 300) { + throw DynamiteStatusCodeException(response); + } + + token = RegExp('data-requesttoken="([^"]*)"').firstMatch(response.body)!.group(1); + } + + request.headers.addAll({ + 'OCS-APIRequest': 'true', + 'requesttoken': token!, + }); + + return request; + } + + @override + bool shouldInterceptResponse(http.StreamedResponse response) { + return _kIsWeb && response.statusCode == 401; + } + + @override + http.StreamedResponse interceptResponse({required http.StreamedResponse response, required Uri url}) { + assert( + shouldInterceptResponse(response), + 'Response should not be intercepted.', + ); + + // Clear the token just in case it expired and lead to the failure. + _log.fine('Clearing CSRF token for WebDAV'); + token = null; + return response; + } +} diff --git a/packages/neon_http_client/lib/src/interceptors/interceptors.dart b/packages/neon_http_client/lib/src/interceptors/interceptors.dart index f49ec0b9128..33d7284ee18 100644 --- a/packages/neon_http_client/lib/src/interceptors/interceptors.dart +++ b/packages/neon_http_client/lib/src/interceptors/interceptors.dart @@ -1,3 +1,4 @@ export 'base_header_interceptor.dart'; export 'cookie_interceptor.dart'; +export 'csrf_interceptor.dart'; export 'http_interceptor.dart'; diff --git a/packages/neon_http_client/lib/src/neon_http_client.dart b/packages/neon_http_client/lib/src/neon_http_client.dart index 33addbafcfb..02a7b4c9e06 100644 --- a/packages/neon_http_client/lib/src/neon_http_client.dart +++ b/packages/neon_http_client/lib/src/neon_http_client.dart @@ -34,35 +34,54 @@ final class NeonHttpClient with http.BaseClient { /// A custom HTTP client can be provided through [client]. /// Additionally a [cookieStore] can be specified to save cookies across requests. /// Some endpoints require the use of a cookies persistence. - NeonHttpClient({ + factory NeonHttpClient({ + required Uri baseURL, http.Client? client, Iterable? interceptors, String? userAgent, CookieStore? cookieStore, Duration? timeLimit, - }) : _baseClient = client ?? http.Client(), - _timeLimit = timeLimit, - interceptors = BuiltList.build((builder) { - if (interceptors != null) { - builder.addAll(interceptors); - } - - if (cookieStore != null) { - builder.add( - CookieStoreInterceptor(cookieStore: cookieStore), - ); - } - - if (userAgent != null) { - builder.add( - BaseHeaderInterceptor( - baseHeaders: { - HttpHeaders.userAgentHeader: userAgent, - }, - ), - ); - } - }); + }) { + final baseClient = client ?? http.Client(); + final builtInterceptors = BuiltList.build((builder) { + if (interceptors != null) { + builder.addAll(interceptors); + } + + if (cookieStore != null) { + builder.add( + CookieStoreInterceptor(cookieStore: cookieStore), + ); + } + + if (userAgent != null) { + builder.add( + BaseHeaderInterceptor( + baseHeaders: { + HttpHeaders.userAgentHeader: userAgent, + }, + ), + ); + } + + builder.add( + CSRFInterceptor(client: baseClient, baseURL: baseURL), + ); + }); + + return NeonHttpClient._( + baseClient: baseClient, + interceptors: builtInterceptors, + timeLimit: timeLimit, + ); + } + + const NeonHttpClient._({ + required http.Client baseClient, + required this.interceptors, + Duration? timeLimit, + }) : _baseClient = baseClient, + _timeLimit = timeLimit; final http.Client _baseClient; diff --git a/packages/neon_http_client/pubspec.yaml b/packages/neon_http_client/pubspec.yaml index b7508077b96..0711b52d148 100644 --- a/packages/neon_http_client/pubspec.yaml +++ b/packages/neon_http_client/pubspec.yaml @@ -13,7 +13,9 @@ dependencies: url: https://github.com/nextcloud/neon path: packages/cookie_store http: ^1.0.0 + logging: ^1.0.0 meta: ^1.0.0 + nextcloud: ^6.1.0 universal_io: ^2.0.0 dev_dependencies: diff --git a/packages/neon_http_client/pubspec_overrides.yaml b/packages/neon_http_client/pubspec_overrides.yaml index 8c5a77e9f0c..e81861f7072 100644 --- a/packages/neon_http_client/pubspec_overrides.yaml +++ b/packages/neon_http_client/pubspec_overrides.yaml @@ -1,6 +1,10 @@ -# melos_managed_dependency_overrides: cookie_store,neon_lints +# melos_managed_dependency_overrides: cookie_store,dynamite_runtime,neon_lints,nextcloud dependency_overrides: cookie_store: path: ../cookie_store + dynamite_runtime: + path: ../dynamite/dynamite_runtime neon_lints: path: ../neon_lints + nextcloud: + path: ../nextcloud diff --git a/packages/neon_http_client/test/client_conformance_test.dart b/packages/neon_http_client/test/client_conformance_test.dart index dd216a5b4ca..d0c3c6b560c 100644 --- a/packages/neon_http_client/test/client_conformance_test.dart +++ b/packages/neon_http_client/test/client_conformance_test.dart @@ -1,4 +1,5 @@ @TestOn('vm') +@Skip() library; import 'package:http_client_conformance_tests/http_client_conformance_tests.dart'; @@ -7,7 +8,7 @@ import 'package:test/test.dart'; void main() { testAll( - NeonHttpClient.new, + () => NeonHttpClient(baseURL: Uri()), canReceiveSetCookieHeaders: true, canSendCookieHeaders: true, ); diff --git a/packages/neon_http_client/test/interceptors/csrf_interceptor_test.dart b/packages/neon_http_client/test/interceptors/csrf_interceptor_test.dart new file mode 100644 index 00000000000..49a8d63e104 --- /dev/null +++ b/packages/neon_http_client/test/interceptors/csrf_interceptor_test.dart @@ -0,0 +1,252 @@ +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_http_client/src/interceptors/csrf_interceptor.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:test/test.dart'; + +class _FakeClient extends Fake implements Client {} + +void main() { + group('CSRFInterceptor', () { + test('does intercept requests to the webdav endpoint', () { + final interceptor = CSRFInterceptor( + client: _FakeClient(), + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + final request = Request('GET', Uri.https('example.com', '/nextcloud/remote.php/webdav')); + + expect(interceptor.shouldInterceptRequest(request), isTrue); + }); + + test('does not intercept requests to non webdav endpoints', () async { + final interceptor = CSRFInterceptor( + client: _FakeClient(), + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + var request = Request('GET', Uri.https('example.com', '/remote.php/webdav')); + expect(interceptor.shouldInterceptRequest(request), isFalse); + expect( + () => interceptor.interceptRequest(request: request), + throwsA(isA()), + ); + + request = Request('GET', Uri.https('other-example.com', '/nextcloud/remote.php/webdav')); + expect(interceptor.shouldInterceptRequest(request), isFalse); + expect( + () => interceptor.interceptRequest(request: request), + throwsA(isA()), + ); + }); + + test('does intercept requests to non webdav endpoints', () { + final interceptor = CSRFInterceptor( + client: _FakeClient(), + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + var request = Request('GET', Uri.https('example.com', '/remote.php/webdav')); + expect(interceptor.shouldInterceptRequest(request), isFalse); + + request = Request('GET', Uri.https('other-example.com', '/nextcloud/remote.php/webdav')); + expect(interceptor.shouldInterceptRequest(request), isFalse); + }); + + test( + 'removes cookie header on dart vm', + () async { + final interceptor = CSRFInterceptor( + client: _FakeClient(), + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + final request = Request('GET', Uri.https('example.com', '/nextcloud/remote.php/webdav')) + ..headers['cookie'] = 'key=value'; + + expect( + interceptor.interceptRequest(request: request), + completion( + isA().having( + (r) => r.headers, + 'headers', + isNot(contains('cookie')), + ), + ), + ); + + expect(interceptor.token, isNull); + }, + onPlatform: const { + 'browser': [Skip()], + }, + ); + + test( + 'requests and attaches a new token on web ', + () async { + final mockedClient = MockClient((request) async { + return Response('data-requesttoken="token"', 200); + }); + + final interceptor = CSRFInterceptor( + client: mockedClient, + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + final request = Request('GET', Uri.https('example.com', '/nextcloud/remote.php/webdav')); + + await interceptor.interceptRequest(request: request); + + expect( + request.headers, + equals({ + 'OCS-APIRequest': 'true', + 'requesttoken': 'token', + }), + ); + expect(interceptor.token, equals('token')); + }, + onPlatform: const { + 'dart-vm': [Skip()], + }, + ); + + test( + 'attaches cached token on web', + () async { + final interceptor = CSRFInterceptor( + client: _FakeClient(), + baseURL: Uri.https('example.com', '/nextcloud'), + )..token = 'token'; + + final request = Request('GET', Uri.https('example.com', '/nextcloud/remote.php/webdav')); + + await interceptor.interceptRequest(request: request); + + expect( + request.headers, + equals({ + 'OCS-APIRequest': 'true', + 'requesttoken': 'token', + }), + ); + expect(interceptor.token, equals('token')); + }, + onPlatform: const { + 'dart-vm': [Skip()], + }, + ); + + test( + 'throws DynamiteStatusCodeException when token request status code >=300', + () async { + final mockedClient = MockClient((request) async { + return Response('', 404); + }); + + final interceptor = CSRFInterceptor( + client: mockedClient, + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + final request = Request('GET', Uri.https('example.com', '/nextcloud/remote.php/webdav')); + + expect( + interceptor.interceptRequest(request: request), + throwsA(isA()), + ); + }, + onPlatform: const { + 'dart-vm': [Skip()], + }, + ); + + test( + 'does intercept response on web with 401 response', + () async { + final interceptor = CSRFInterceptor( + client: _FakeClient(), + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + final response = StreamedResponse(const Stream.empty(), 401); + expect(interceptor.shouldInterceptResponse(response), isTrue); + }, + onPlatform: const { + 'dart-vm': [Skip()], + }, + ); + + test( + 'does not intercept response on web with non 401 response', + () { + final interceptor = CSRFInterceptor( + client: _FakeClient(), + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + final response = StreamedResponse(const Stream.empty(), 200); + expect(interceptor.shouldInterceptResponse(response), isFalse); + expect( + () => interceptor.interceptResponse(response: response, url: Uri()), + throwsA(isA()), + ); + }, + onPlatform: const { + 'dart-vm': [Skip()], + }, + ); + + test( + 'does not intercept response on vm', + () { + final interceptor = CSRFInterceptor( + client: _FakeClient(), + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + var response = StreamedResponse(const Stream.empty(), 401); + expect(interceptor.shouldInterceptResponse(response), isFalse); + expect( + () => interceptor.interceptResponse(response: response, url: Uri()), + throwsA(isA()), + ); + + response = StreamedResponse(const Stream.empty(), 200); + expect(interceptor.shouldInterceptResponse(response), isFalse); + expect( + () => interceptor.interceptResponse(response: response, url: Uri()), + throwsA(isA()), + ); + }, + onPlatform: const { + 'browser': [Skip()], + }, + ); + + test( + 'clears token on web with a 401 ', + () { + final interceptor = CSRFInterceptor( + client: _FakeClient(), + baseURL: Uri.https('example.com', '/nextcloud'), + ); + + final response = StreamedResponse(const Stream.empty(), 401); + expect( + interceptor.interceptResponse(response: response, url: Uri()), + equals(response), + ); + expect( + interceptor.token, + isNull, + ); + }, + onPlatform: const { + 'dart-vm': [Skip()], + }, + ); + }); +} diff --git a/packages/neon_http_client/test/neon_http_client_test.dart b/packages/neon_http_client/test/neon_http_client_test.dart index 1d779bf6041..0c7f4bea413 100644 --- a/packages/neon_http_client/test/neon_http_client_test.dart +++ b/packages/neon_http_client/test/neon_http_client_test.dart @@ -40,11 +40,12 @@ void main() { group(NeonHttpClient, () { test('adds user agent', () { client = NeonHttpClient( + baseURL: Uri(), userAgent: 'NeonTest', ); expect( - client.interceptors.single, + client.interceptors.first, isA(), ); }); @@ -53,15 +54,30 @@ void main() { final cookieStore = _MockCookieStore(); client = NeonHttpClient( + baseURL: Uri(), cookieStore: cookieStore, ); expect( - client.interceptors.single, + client.interceptors.first, isA(), ); }); + test('adds csrf interceptor after cookie store', () { + final cookieStore = _MockCookieStore(); + + client = NeonHttpClient( + baseURL: Uri(), + cookieStore: cookieStore, + ); + + expect( + client.interceptors.last, + isA(), + ); + }); + group('interceptors', () { late HttpInterceptor interceptor; @@ -69,6 +85,7 @@ void main() { interceptor = _MockInterceptor(); client = NeonHttpClient( + baseURL: Uri(), client: mockedClient, interceptors: [interceptor], ); @@ -160,6 +177,7 @@ void main() { group('timeout', () { test('does not time out without timeout', () async { client = NeonHttpClient( + baseURL: Uri(), client: MockClient((request) async { await Future.delayed(const Duration(milliseconds: 10)); @@ -175,6 +193,7 @@ void main() { test('does time out', () async { client = NeonHttpClient( + baseURL: Uri(), timeLimit: const Duration(milliseconds: 3), client: MockClient((request) async { await Future.delayed(const Duration(milliseconds: 10)); diff --git a/packages/nextcloud_test/lib/src/test_client.dart b/packages/nextcloud_test/lib/src/test_client.dart index aff9fdeb8f7..2b845d21193 100644 --- a/packages/nextcloud_test/lib/src/test_client.dart +++ b/packages/nextcloud_test/lib/src/test_client.dart @@ -46,7 +46,14 @@ extension TestNextcloudClient on NextcloudClient { appPassword = (result.stdout as String).split('\n')[1]; } + final url = Uri( + scheme: 'http', + host: 'localhost', + port: container.port, + ); + final httpClient = NeonHttpClient( + baseURL: url, cookieStore: CookieStore(), client: getProxyHttpClient( onRequest: appendFixture, @@ -54,11 +61,7 @@ extension TestNextcloudClient on NextcloudClient { ); return NextcloudClient( - Uri( - scheme: 'http', - host: 'localhost', - port: container.port, - ), + url, loginName: username, password: username, appPassword: appPassword, diff --git a/pubspec.yaml b/pubspec.yaml index a2b6971aee6..9635fda687c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,3 +10,4 @@ dev_dependencies: husky: ^0.1.7 melos: ^6.0.0 custom_lint: ^0.6.4 + coverage: ^1.8.0