Skip to content

Commit ea5e7c4

Browse files
feat(transport): introduce typed HTTP transport and enforce security boundaries
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fd7d40d commit ea5e7c4

13 files changed

Lines changed: 1937 additions & 182 deletions

lib/src/browser/xplat_browser.dart

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ import 'package:github/src/common.dart';
55
import 'package:github/src/common/xplat_common.dart'
66
show findAuthenticationInMap;
77

8-
/// Looks for GitHub Authentication information from the browser
8+
/// Looks for GitHub Authentication information from the browser.
99
///
10-
/// Checks for query strings first, then local storage using keys in [COMMON_GITHUB_TOKEN_ENV_KEYS].
11-
/// If the above fails, the GITHUB_USERNAME and GITHUB_PASSWORD keys will be checked.
12-
Authentication findAuthenticationFromEnvironment() {
13-
// search the query string parameters first
14-
var auth = findAuthenticationInMap(_parseQuery(window.location.href));
10+
/// NOTE: Passing credentials in query strings is insecure as tokens can be
11+
/// leaked via logs, browser history, screenshots, and Referer headers.
12+
/// Extracting tokens from URL query parameters is disabled by default and deprecated.
13+
/// Explicitly instantiate [Authentication] or use `window.sessionStorage`.
14+
Authentication findAuthenticationFromEnvironment(
15+
{bool allowQueryAuth = false}) {
16+
Authentication? auth;
17+
if (allowQueryAuth) {
18+
auth = findAuthenticationInMap(_parseQuery(window.location.href));
19+
}
1520
auth ??= findAuthenticationInMap(window.sessionStorage);
1621
return auth ?? const Authentication.anonymous();
1722
}

lib/src/common.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ library;
55
export 'package:github/src/common/activity_service.dart';
66
export 'package:github/src/common/authorizations_service.dart';
77
export 'package:github/src/common/checks_service.dart';
8+
export 'package:github/src/common/generated/rest_contracts.g.dart';
89
export 'package:github/src/common/gists_service.dart';
910
export 'package:github/src/common/git_service.dart';
1011
export 'package:github/src/common/github.dart';
@@ -40,6 +41,10 @@ export 'package:github/src/common/orgs_service.dart';
4041
export 'package:github/src/common/pulls_service.dart';
4142
export 'package:github/src/common/repos_service.dart';
4243
export 'package:github/src/common/search_service.dart';
44+
export 'package:github/src/common/transport/error_decoder.dart';
45+
export 'package:github/src/common/transport/request.dart';
46+
export 'package:github/src/common/transport/retry_policy.dart';
47+
export 'package:github/src/common/transport/transport.dart';
4348
export 'package:github/src/common/url_shortener_service.dart';
4449
export 'package:github/src/common/users_service.dart';
4550
export 'package:github/src/common/util/auth.dart';

lib/src/common/github.dart

Lines changed: 76 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,48 @@ class GitHub {
2323
this.endpoint = 'https://api.github.com',
2424
this.version = '2022-11-28',
2525
http.Client? client,
26-
}) : client = client ?? http.Client();
26+
Set<String>? trustedOrigins,
27+
this.allowInsecureAuth = false,
28+
this.retryPolicy = const RetryPolicy(),
29+
HttpTransport? transport,
30+
}) : client = client ?? http.Client(),
31+
trustedOrigins = trustedOrigins ?? _defaultTrustedOrigins(endpoint),
32+
_transport = transport;
33+
34+
static Set<String> _defaultTrustedOrigins(String endpoint) {
35+
final origins = <String>{};
36+
final uri = Uri.tryParse(endpoint);
37+
if (uri != null && uri.hasScheme && uri.hasAuthority) {
38+
origins.add(uri.origin);
39+
} else {
40+
origins.add('https://api.github.com');
41+
}
42+
origins.add('https://uploads.github.com');
43+
return origins;
44+
}
45+
46+
/// Policy governing retries, exponential backoff, and rate limits.
47+
final RetryPolicy retryPolicy;
48+
49+
/// Underlying injectable HTTP transport.
50+
HttpTransport get transport => _transport ??= HttpTransport(
51+
github: this,
52+
client: client,
53+
retryPolicy: retryPolicy,
54+
trustedOrigins: trustedOrigins,
55+
allowInsecureAuth: allowInsecureAuth,
56+
endpoint: endpoint,
57+
);
58+
HttpTransport? _transport;
59+
60+
/// Set of trusted origins permitted to receive authentication credentials.
61+
/// Defaults to the origin of [endpoint] (e.g. `https://api.github.com`) and
62+
/// `https://uploads.github.com`.
63+
final Set<String> trustedOrigins;
64+
65+
/// Whether to allow sending credentials over unencrypted HTTP.
66+
/// Defaults to `false` for security. Set to `true` only for local test servers.
67+
final bool allowInsecureAuth;
2768

2869
static const _ratelimitLimitHeader = 'x-ratelimit-limit';
2970
static const _ratelimitResetHeader = 'x-ratelimit-reset';
@@ -51,6 +92,7 @@ class GitHub {
5192
final http.Client client;
5293

5394
ActivityService? _activity;
95+
// ignore: deprecated_member_use_from_same_package
5496
AuthorizationsService? _authorizations;
5597
GistsService? _gists;
5698
GitService? _git;
@@ -60,6 +102,7 @@ class GitHub {
60102
PullRequestsService? _pullRequests;
61103
RepositoriesService? _repositories;
62104
SearchService? _search;
105+
// ignore: deprecated_member_use_from_same_package
63106
UrlShortenerService? _urlShortener;
64107
UsersService? _users;
65108
ChecksService? _checks;
@@ -98,7 +141,9 @@ class GitHub {
98141
///
99142
/// Note: You can only access this API via Basic Authentication using your
100143
/// username and password, not tokens.
144+
// ignore: deprecated_member_use_from_same_package
101145
AuthorizationsService get authorizations =>
146+
// ignore: deprecated_member_use_from_same_package
102147
_authorizations ??= AuthorizationsService(this);
103148

104149
/// Service for gist related methods of the GitHub API.
@@ -129,7 +174,9 @@ class GitHub {
129174
SearchService get search => _search ??= SearchService(this);
130175

131176
/// Service to provide a handy method to access GitHub's url shortener.
177+
// ignore: deprecated_member_use_from_same_package
132178
UrlShortenerService get urlShortener =>
179+
// ignore: deprecated_member_use_from_same_package
133180
_urlShortener ??= UrlShortenerService(this);
134181

135182
/// Service for user related methods of the GitHub API.
@@ -330,10 +377,16 @@ class GitHub {
330377
fail: fail,
331378
);
332379

380+
if (response.statusCode == 204 || response.body.isEmpty) {
381+
return null as T;
382+
}
383+
333384
final json = jsonDecode(response.body);
334385

335-
final returnValue = convert(json) as T;
336-
_applyExpandos(returnValue, response);
386+
final returnValue = convert(json as S) as T;
387+
if (returnValue != null) {
388+
_applyExpandos(returnValue, response);
389+
}
337390
return returnValue;
338391
}
339392

@@ -355,135 +408,27 @@ class GitHub {
355408
void Function(http.Response response)? fail,
356409
String? preview,
357410
}) async {
358-
if (rateLimitRemaining != null && rateLimitRemaining! <= 0) {
359-
assert(rateLimitReset != null);
360-
final now = DateTime.now();
361-
final waitTime = rateLimitReset!.difference(now);
362-
await Future.delayed(waitTime);
363-
}
364-
365411
headers ??= <String, String>{};
366412

367413
if (preview != null) {
368414
headers['Accept'] = preview;
369415
}
370416

371-
final authHeaderValue = auth.authorizationHeaderValue();
372-
if (authHeaderValue != null) {
373-
headers.putIfAbsent('Authorization', () => authHeaderValue);
374-
}
375-
376-
// See https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#user-agent-required
377-
headers.putIfAbsent('User-Agent', () => auth.username ?? 'github.dart');
378-
379-
if (method == 'PUT' && body == null) {
380-
headers.putIfAbsent('Content-Length', () => '0');
381-
}
382-
383-
var queryString = '';
384-
385-
if (params != null) {
386-
queryString = buildQueryString(params);
387-
}
388-
389-
final url = StringBuffer();
390-
391-
if (path.startsWith('http://') || path.startsWith('https://')) {
392-
url.write(path);
393-
url.write(queryString);
394-
} else {
395-
url.write(endpoint);
396-
if (!path.startsWith('/')) {
397-
url.write('/');
398-
}
399-
url.write(path);
400-
url.write(queryString);
401-
}
402-
403-
final request = http.Request(method, Uri.parse(url.toString()));
404-
request.headers.addAll(headers);
405-
if (body != null) {
406-
if (body is List<int>) {
407-
request.bodyBytes = body;
408-
} else {
409-
request.body = body.toString();
410-
}
411-
}
412-
413-
final streamedResponse = await client.send(request);
414-
415-
final response = await http.Response.fromStream(streamedResponse);
417+
final apiRequest = ApiRequest(
418+
method: method,
419+
path: path,
420+
headers: headers,
421+
params: params ?? const {},
422+
body: body,
423+
successStatuses: statusCode != null ? {statusCode} : null,
424+
);
416425

417-
_updateRateLimit(response.headers);
418-
if (statusCode != null && statusCode != response.statusCode) {
419-
if (fail != null) {
420-
fail(response);
421-
}
422-
handleStatusCode(response);
423-
} else {
424-
return response;
425-
}
426+
return transport.execute(apiRequest, fail: fail);
426427
}
427428

428-
///
429429
/// Internal method to handle status codes
430-
///
431430
Never handleStatusCode(http.Response response) {
432-
String? message = '';
433-
List<Map<String, String>>? errors;
434-
if (response.headers['content-type']!.contains('application/json')) {
435-
try {
436-
final json = jsonDecode(response.body);
437-
message = json['message'];
438-
if (json['errors'] != null) {
439-
try {
440-
errors = List<Map<String, String>>.from(json['errors']);
441-
} catch (_) {
442-
errors = [
443-
{'code': json['errors'].toString()}
444-
];
445-
}
446-
}
447-
} catch (ex) {
448-
throw UnknownError(this, ex.toString());
449-
}
450-
}
451-
switch (response.statusCode) {
452-
case 404:
453-
throw NotFound(this, 'Requested Resource was Not Found');
454-
case 401:
455-
throw AccessForbidden(this);
456-
case 400:
457-
if (message == 'Problems parsing JSON') {
458-
throw InvalidJSON(this, message);
459-
} else if (message == 'Body should be a JSON Hash') {
460-
throw InvalidJSON(this, message);
461-
} else {
462-
throw BadRequest(this);
463-
}
464-
case 422:
465-
final buff = StringBuffer();
466-
buff.writeln();
467-
buff.writeln(' Message: $message');
468-
if (errors != null) {
469-
buff.writeln(' Errors:');
470-
for (final error in errors) {
471-
final resource = error['resource'];
472-
final field = error['field'];
473-
final code = error['code'];
474-
buff
475-
..writeln(' Resource: $resource')
476-
..writeln(' Field $field')
477-
..write(' Code: $code');
478-
}
479-
}
480-
throw ValidationFailed(this, buff.toString());
481-
case 500:
482-
case 502:
483-
case 504:
484-
throw ServerError(this, response.statusCode, message);
485-
}
486-
throw UnknownError(this, message);
431+
ErrorDecoder.decode(this, response);
487432
}
488433

489434
/// Disposes of this GitHub Instance.
@@ -493,16 +438,21 @@ class GitHub {
493438
client.close();
494439
}
495440

496-
void _updateRateLimit(Map<String, String> headers) {
441+
/// Updates rate limit fields from HTTP response [headers].
442+
void updateRateLimit(Map<String, String> headers) {
497443
if (headers.containsKey(_ratelimitLimitHeader)) {
498-
_rateLimitLimit = int.parse(headers[_ratelimitLimitHeader]!);
499-
_rateLimitRemaining = int.parse(headers[_ratelimitRemainingHeader]!);
500-
_rateLimitReset = int.parse(headers[_ratelimitResetHeader]!);
444+
_rateLimitLimit = int.tryParse(headers[_ratelimitLimitHeader] ?? '');
445+
_rateLimitRemaining =
446+
int.tryParse(headers[_ratelimitRemainingHeader] ?? '');
447+
_rateLimitReset = int.tryParse(headers[_ratelimitResetHeader] ?? '');
501448
}
502449
}
503450
}
504451

505-
void _applyExpandos(dynamic target, http.Response response) {
452+
void _applyExpandos(Object? target, http.Response response) {
453+
if (target == null || target is String || target is num || target is bool) {
454+
return;
455+
}
506456
_etagExpando[target] = response.headers['etag'];
507457
if (response.headers['date'] != null) {
508458
_dateExpando[target] = http_parser.parseHttpDate(response.headers['date']!);

0 commit comments

Comments
 (0)