Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>.json`,
`/dist/reason-dev/<slug>/<slug>-<version>.zip`. Key via `Authorization: Bearer`
only. Same rule as above: ungated while `SIMPLE_UPDATE_KEY` is unset, so a
`/dist/reason-dev/<slug>/<slug>-<version>.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.

Expand Down Expand Up @@ -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":"<key>"}}`).
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
Expand All @@ -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
Expand Down
26 changes: 22 additions & 4 deletions includes/Wpup/ComposerRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>". The key is never accepted from the query
* string here, because Composer records download URLs in composer.lock.
* "Authorization: Bearer <key>", 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';
Expand Down Expand Up @@ -84,12 +86,28 @@ public function handle($path, Wpup_Headers $headers) {
return Wpup_ComposerResponse::error(404, 'Not found.');
}

/**
* "Bearer <key>", or "Basic base64(<any user>:<key>)". 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) {
Expand Down
14 changes: 13 additions & 1 deletion tests/composer-repository-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
Loading