diff --git a/docs/runbook.md b/docs/runbook.md index b23c2b2..8adb0f4 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -7,8 +7,10 @@ `SIMPLE_UPDATE_KEY` unset, this route is ungated — which is production's state until the key is enforced. * Composer routes (vnext sites): `/packages.json`, `/p2/reason-dev/.json`, - `/dist/reason-dev//-.zip`. Key via `Authorization: Bearer` - only. Same rule as above: ungated while `SIMPLE_UPDATE_KEY` is unset, so a + `/dist/reason-dev//-.zip`. Key via `Authorization: Bearer`, + or as the HTTP Basic password with any username (for Dependabot, whose + `composer-repository` registry can only send a username and password). + Never from the query string. Same rule as above: ungated while `SIMPLE_UPDATE_KEY` is unset, so a Composer site can build against production before the legacy fleet sends the key. Setting the key gates both routes in the same deploy. @@ -43,7 +45,9 @@ One key serves the whole fleet. Rotation touches, in this order: the old key until their constant changes. 2. Every vnext site repo's `COMPOSER_AUTH_JSON` secret (`{"bearer":{"packages.reason.com":""}}`). -3. The org secret `REASON_PACKAGES_UPDATES_KEY` used by plugin CI. +3. The org secret `REASON_PACKAGES_UPDATES_KEY` used by plugin CI, in both + the Actions and the Dependabot secret stores (Dependabot runs read only the + latter, including `dependabot.yml` registries). 4. SSM `/wp-update-server/prod/SIMPLE_UPDATE_KEY`, then redeploy. Step 1 only applies once legacy sites are actually sending the key; until @@ -52,6 +56,28 @@ window where either legacy sites or Composer builds fail against the key in place at that moment. Accepting two keys at once during rotation would remove that window; that's possible future work, not implemented here. +## Dependabot in plugin and theme repos + +A repo whose Dependabot `composer` job resolves `reason-dev/*` packages needs a +registry entry, because Dependabot can't send a bearer token: + +```yaml +registries: + packages-reason: + type: composer-repository + url: https://packages.reason.com + username: dependabot + password: ${{ secrets.REASON_PACKAGES_UPDATES_KEY }} + +updates: + - package-ecosystem: composer + directory: / + registries: [packages-reason] +``` + +The username is ignored. The secret must exist in the org's **Dependabot** +secret store; the Actions copy is not visible to Dependabot. + ## Test stage `dev` stage: SSM `/wp-update-server/dev/SIMPLE_UPDATE_KEY`, stack diff --git a/includes/Wpup/ComposerRepository.php b/includes/Wpup/ComposerRepository.php index ade10f7..db632f6 100644 --- a/includes/Wpup/ComposerRepository.php +++ b/includes/Wpup/ComposerRepository.php @@ -12,8 +12,10 @@ * * The key rule is the update-checker route's: while SIMPLE_UPDATE_KEY is unset * the routes are open, and once it is set every route requires it as - * "Authorization: Bearer ". The key is never accepted from the query - * string here, because Composer records download URLs in composer.lock. + * "Authorization: Bearer ", or as the password of HTTP Basic auth (any + * username) for clients that cannot send a bearer token. The key is never + * accepted from the query string here, because Composer records download URLs + * in composer.lock. */ class Wpup_ComposerRepository { const VENDOR = 'reason-dev'; @@ -84,12 +86,28 @@ public function handle($path, Wpup_Headers $headers) { return Wpup_ComposerResponse::error(404, 'Not found.'); } + /** + * "Bearer ", or "Basic base64(:)". Basic exists for + * clients that can only send a username and password: Dependabot's + * composer-repository registry has no token option. + */ protected function isAuthorized(Wpup_Headers $headers) { $header = $headers->get('Authorization', ''); - if ( !is_string($header) || stripos($header, 'Bearer ') !== 0 ) { + if ( !is_string($header) ) { return false; } - return hash_equals($this->key, trim(substr($header, 7))); + if ( stripos($header, 'Bearer ') === 0 ) { + return hash_equals($this->key, trim(substr($header, 7))); + } + if ( stripos($header, 'Basic ') === 0 ) { + $decoded = base64_decode(trim(substr($header, 6)), true); + if ( !is_string($decoded) || strpos($decoded, ':') === false ) { + return false; + } + list(, $password) = explode(':', $decoded, 2); + return hash_equals($this->key, $password); + } + return false; } protected function serveP2($slug) { diff --git a/tests/composer-repository-test.php b/tests/composer-repository-test.php index 59c12a5..46bebc9 100644 --- a/tests/composer-repository-test.php +++ b/tests/composer-repository-test.php @@ -66,7 +66,19 @@ function s3_error($status, $code) { check('no header: 401', $res->status === 401); check('no header: WWW-Authenticate set', isset($res->headers['WWW-Authenticate'])); check('wrong key: 401', $repo->handle('/packages.json', bearer('nope'))->status === 401); -check('basic scheme: 401', $repo->handle('/packages.json', new Wpup_Headers(array('Authorization' => 'Basic ' . base64_encode('x:' . KEY))))->status === 401); +function basic($credentials) { + return new Wpup_Headers(array('Authorization' => 'Basic ' . base64_encode($credentials))); +} +check('basic, key as password: 200', $repo->handle('/packages.json', basic('x:' . KEY))->status === 200); +check('basic, empty username: 200', $repo->handle('/packages.json', basic(':' . KEY))->status === 200); +check('basic, extra text after the key: 401', $repo->handle('/packages.json', basic('x:' . KEY . ':extra'))->status === 401); +check('lowercase basic scheme: 200', $repo->handle('/packages.json', new Wpup_Headers(array('Authorization' => 'basic ' . base64_encode('x:' . KEY))))->status === 200); +check('basic, wrong password: 401', $repo->handle('/packages.json', basic('x:nope'))->status === 401); +check('basic, key as username: 401', $repo->handle('/packages.json', basic(KEY . ':x'))->status === 401); +check('basic, no colon: 401', $repo->handle('/packages.json', basic(KEY))->status === 401); +check('basic, not base64: 401', $repo->handle('/packages.json', new Wpup_Headers(array('Authorization' => 'Basic !!!')))->status === 401); +check('basic, guards the dist route: 401', $repo->handle('/dist/reason-dev/acme-widget/acme-widget-1.4.2.zip', basic('x:nope'))->status === 401); +check('unknown scheme: 401', $repo->handle('/packages.json', new Wpup_Headers(array('Authorization' => 'Token ' . KEY)))->status === 401); check('lowercase bearer scheme: 200', $repo->handle('/packages.json', new Wpup_Headers(array('Authorization' => 'bearer ' . KEY)))->status === 200); // --- packages.json ---