fix(engine-b): address Codex round 3 (webhook email/replay/rotation, db secret)
- Require a real customer email (no unknown@ fallback merging customers). - Stripe signature: enforce 5-min replay tolerance + accept any rotating v1. - DeployApplicationStack no longer persists the DB password in the run context. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
e6bd6c9354
commit
fffcecb152
|
|
@ -38,9 +38,16 @@ class StripeWebhookController extends Controller
|
|||
|
||||
$meta = $object['metadata'] ?? [];
|
||||
|
||||
// A real email is required — never merge unrelated customers under a
|
||||
// manufactured address or send credentials into the void.
|
||||
$email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null);
|
||||
if (blank($email)) {
|
||||
return response()->json(['ignored' => 'no_customer_email']);
|
||||
}
|
||||
|
||||
$action->fromStripeEvent([
|
||||
'id' => (string) ($event['id'] ?? ''),
|
||||
'email' => $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? 'unknown@example.com'),
|
||||
'email' => $email,
|
||||
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
|
||||
'stripe_customer_id' => $object['customer'] ?? null,
|
||||
'plan' => $meta['plan'] ?? 'start',
|
||||
|
|
@ -52,6 +59,9 @@ class StripeWebhookController extends Controller
|
|||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
/** Stripe's default replay tolerance, in seconds. */
|
||||
private const SIGNATURE_TOLERANCE = 300;
|
||||
|
||||
/** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */
|
||||
private function signatureValid(string $payload, string $header, string $secret): bool
|
||||
{
|
||||
|
|
@ -59,20 +69,34 @@ class StripeWebhookController extends Controller
|
|||
return false;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
$timestamp = null;
|
||||
$signatures = [];
|
||||
foreach (explode(',', $header) as $segment) {
|
||||
[$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
|
||||
$parts[$key] = $value;
|
||||
if ($key === 't') {
|
||||
$timestamp = $value;
|
||||
} elseif ($key === 'v1') {
|
||||
$signatures[] = $value; // keep every v1 (secret rotation sends several)
|
||||
}
|
||||
}
|
||||
|
||||
$timestamp = $parts['t'] ?? null;
|
||||
$signature = $parts['v1'] ?? null;
|
||||
if ($timestamp === null || $signature === null) {
|
||||
if ($timestamp === null || $signatures === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject replays outside the tolerance window.
|
||||
if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
|
||||
|
||||
return hash_equals($expected, $signature);
|
||||
foreach ($signatures as $signature) {
|
||||
if (hash_equals($expected, $signature)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ class DeployApplicationStack extends CustomerStep
|
|||
$vmid = (int) $run->context('vmid');
|
||||
$pve = $this->pve->forHost($instance->host);
|
||||
|
||||
// Deploy once (guarded); the generated DB password is transient.
|
||||
// Deploy once (guarded). The generated DB password is written straight
|
||||
// into the guest's .env and never persisted in the run context.
|
||||
if (! $run->context('stack_deployed')) {
|
||||
$dbPassword = Str::random(28);
|
||||
$run->mergeContext(['db_password' => $dbPassword]);
|
||||
|
||||
$this->guest($pve, $run,
|
||||
'install -d /opt/nextcloud && printf %s '.escapeshellarg('MYSQL_PASSWORD='.$dbPassword."\n").
|
||||
|
|
@ -48,8 +48,6 @@ class DeployApplicationStack extends CustomerStep
|
|||
return StepResult::poll(15, 'waiting for application stack');
|
||||
}
|
||||
|
||||
$run->forgetContext('db_password'); // scrub the secret once the stack is up
|
||||
|
||||
return StepResult::advance();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,18 +187,23 @@ it('configures the guest network, captures the ip, and firewall', function () {
|
|||
});
|
||||
|
||||
// 8. DeployApplicationStack
|
||||
it('deploys the stack, polling until healthy, and scrubs the db password', function () {
|
||||
it('deploys the stack, polling until healthy, without persisting the db password', function () {
|
||||
$s = fakeServices();
|
||||
['run' => $run] = reservedRun();
|
||||
|
||||
// not healthy yet → poll, secret still present
|
||||
// not healthy yet → poll; the db password is never in the run context
|
||||
expect(app(DeployApplicationStack::class)->execute($run)->type)->toBe('poll')
|
||||
->and($run->fresh()->context('db_password'))->not->toBeNull();
|
||||
->and($run->fresh()->context('db_password'))->toBeNull()
|
||||
->and($run->fresh()->context('stack_deployed'))->toBeTrue()
|
||||
->and($s['pve']->guestRan('docker compose up'))->toBeTrue();
|
||||
|
||||
// healthy → advance, secret scrubbed
|
||||
// healthy → advance
|
||||
$s['pve']->guestScript('status.php', 0, 'ok');
|
||||
expect(app(DeployApplicationStack::class)->execute($run->fresh())->type)->toBe('advance')
|
||||
->and($run->fresh()->context('db_password'))->toBeNull();
|
||||
expect(app(DeployApplicationStack::class)->execute($run->fresh())->type)->toBe('advance');
|
||||
|
||||
// the persisted context never contains a plaintext DB credential
|
||||
$raw = \DB::table('provisioning_runs')->where('id', $run->id)->value('context');
|
||||
expect($raw)->not->toContain('MYSQL_PASSWORD');
|
||||
});
|
||||
|
||||
// 9. ConfigureNextcloud
|
||||
|
|
|
|||
|
|
@ -54,6 +54,18 @@ it('ignores unrelated event types', function () {
|
|||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('ignores a paid checkout without a customer email', function () {
|
||||
Queue::fake();
|
||||
|
||||
$event = stripeEvent('evt_noemail');
|
||||
unset($event['data']['object']['customer_details'], $event['data']['object']['customer_email'], $event['data']['object']['metadata']['email']);
|
||||
|
||||
$this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['ignored' => 'no_customer_email']);
|
||||
|
||||
expect(Order::query()->where('stripe_event_id', 'evt_noemail')->exists())->toBeFalse();
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('ignores a completed but unpaid checkout session', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
|
@ -78,7 +90,7 @@ it('accepts a valid signature', function () {
|
|||
config()->set('services.stripe.webhook_secret', 'whsec_test');
|
||||
|
||||
$payload = json_encode(stripeEvent('evt_ok'));
|
||||
$timestamp = 1700000000;
|
||||
$timestamp = time(); // within Stripe's replay tolerance
|
||||
$signature = hash_hmac('sha256', $timestamp.'.'.$payload, 'whsec_test');
|
||||
|
||||
$this->call(
|
||||
|
|
|
|||
Loading…
Reference in New Issue