Skip to content
Open
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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,8 @@ every time you push code. More simple way use group webhooks, to prevent from be

| Provider | Group webhook support | Target Path |
|-----------|-----------------------|-----------------------------------------------------------|
| GitHub | Yes | `https://example.org/api/github?token=` |
| GitHub | Yes (signature-based) | `https://example.org/api/hooks/github` |
| GitHub | Yes (legacy token) | `https://example.org/api/github?token=` |
| GitLab | Only paid plan | `https://example.org/api/update-package?token=` |
| Gitea | Yes | `https://example.org/api/update-package?token=` |
| Bitbucket | Yes | `https://example.org/api/bitbucket?token=` |
Expand All @@ -380,7 +381,18 @@ To enable the Group GitLab webhook you must have the paid plan.
Go to your GitLab Group > Settings > Webhooks.
Enter `https://<app>/api/update-package?token=user:token` as URL.

#### GitHub Webhooks
#### GitHub Organization Webhooks (recommended)
For organization-wide webhooks, use signature-based authentication instead of putting a user API token in the URL.

1. In Packeton, go to Settings > Incoming webhook secrets and create a secret.
2. In GitHub, go to Organization Settings > Webhooks > Add webhook.
3. Set the payload URL to `https://<app>/api/hooks/github`.
4. Select `application/json`, paste the generated secret, and subscribe to push events.

Packeton validates GitHub's `X-Hub-Signature-256` header before updating packages. Existing token-based webhook URLs
remain available for backwards compatibility.

#### GitHub Repository Webhooks (legacy)
To enable the GitHub webhook go to your GitHub repository. Click the "Settings" button, click "Webhooks".
Add a new hook. Enter `https://<app>/api/github?token=user:token` as URL.

Expand Down
4 changes: 4 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
github_webhook:
pattern: ^/api/hooks/github$
security: false
packages:
pattern: (^(.+\.json$|/p/|/mirror/|/zipball/|/feeds/.+(\.rss|\.atom)|/packages/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?(\.json|/changelog)|/packages/list\.json|/packages/upload/|/downloads/|/api/))+
api_basic:
Expand Down Expand Up @@ -72,6 +75,7 @@ security:
# Maintainers
- { path: (^(/users/(.+)/packages))+, roles: ROLE_MAINTAINER }
- { path: (^(/users/(.+)/favorites))+, roles: ROLE_MAINTAINER }
- { path: ^/api/hooks/github$, roles: PUBLIC_ACCESS }
- { path: (^(/metadata/changes.json$|/explore|/jobs/|/archive/|/api/hooks/))+, roles: ROLE_MAINTAINER }

# Secured part of the site
Expand Down
1 change: 1 addition & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ parameters:
- 'Packeton\Entity\SshCredentials'
- 'Packeton\Entity\ApiToken'
- 'Packeton\Entity\OAuthIntegration'
- 'Packeton\Entity\WebhookSecret'
security_policy_forbidden_properties:
'Packeton\Entity\User': ['apiToken', 'githubToken', 'password', 'salt']
'Packeton\Entity\Package': ['credentials']
Expand Down
17 changes: 15 additions & 2 deletions docs/usage/update-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ You can use GitLab, GitHub, and Bitbucket project post-receive hook to keep your

## Into

Webhook API request authorization with minimum access level `ROLE_MAINTAINER`.
Token-based webhook API request authorization requires a minimum access level of `ROLE_MAINTAINER`.
You can use `token` query parameter with `<username:api_token>` to call it.

Also support Packagist.org authorization with `username` and `apiToken` query parameters.
Expand All @@ -29,7 +29,20 @@ To enable the Group GitLab webhook you must have the paid plan.
Go to your GitLab Group > Settings > Webhooks.
Enter `https://<app>/api/update-package?token=user:token` as URL.

## GitHub Webhooks
## GitHub Organization Webhooks (recommended)

Organization webhooks can authenticate with GitHub's `X-Hub-Signature-256` header, without exposing a user API token
in the webhook URL.

1. In Packeton, go to Settings > Incoming webhook secrets and create a secret.
2. In GitHub, go to Organization Settings > Webhooks > Add webhook.
3. Set the payload URL to `https://<app>/api/hooks/github` and the content type to `application/json`.
4. Paste the generated secret and subscribe to push events.

The secret is shown once and stored encrypted by Packeton. Existing token-based endpoints remain available for
backwards compatibility.

## GitHub Repository Webhooks (legacy)
To enable the GitHub webhook go to your GitHub repository. Click the "Settings" button, click "Webhooks".
Add a new hook. Enter `https://<app>/api/github?token=user:token` as URL.

Expand Down
80 changes: 80 additions & 0 deletions src/Controller/Api/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Packeton\Entity\Package;
use Packeton\Entity\User;
use Packeton\Entity\Webhook;
use Packeton\Entity\WebhookSecret;
use Packeton\Integrations\IntegrationRegistry;
use Packeton\Integrations\Model\AppUtils;
use Packeton\Model\AutoHookUser;
Expand All @@ -21,6 +22,7 @@
use Packeton\Service\JobPersister;
use Packeton\Service\Scheduler;
use Packeton\Service\SubRepositoryHelper;
use Packeton\Service\WebhookSignatureValidator;
use Packeton\Util\PacketonUtils;
use Packeton\Webhook\HookBus;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -155,6 +157,84 @@ public function updatePackageAction(Request $request, #[Vars] ?Package $package
return $this->schedulePostJobs($packages);
}

#[Route('/api/hooks/github', name: 'github_secure_postreceive', methods: ['POST'])]
public function secureGitHubWebhookAction(Request $request, WebhookSignatureValidator $signatureValidator): Response
{
$secretRepository = $this->registry->getRepository(WebhookSecret::class);
$secrets = $secretRepository->findSecretValues();
if (!$secrets) {
return new JsonResponse(
['status' => 'error', 'message' => 'No incoming webhook secrets are configured'],
Response::HTTP_SERVICE_UNAVAILABLE,
);
}

$signature = $request->headers->get(WebhookSignatureValidator::SIGNATURE_HEADER);
if (null === $signature || '' === $signature) {
$this->logger->warning('GitHub webhook signature validation failed: missing signature header', [
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent'),
]);

return new JsonResponse(
['status' => 'error', 'message' => 'Missing X-Hub-Signature-256 header'],
Response::HTTP_UNAUTHORIZED,
);
}

$matchedSecretId = $signatureValidator->findMatchingSecretId($request->getContent(), $signature, $secrets);
if (null === $matchedSecretId) {
$this->logger->warning('GitHub webhook signature validation failed: invalid signature', [
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent'),
]);

return new JsonResponse(
['status' => 'error', 'message' => 'Invalid signature'],
Response::HTTP_FORBIDDEN,
);
}

$secret = $secretRepository->find($matchedSecretId);
if (null !== $secret) {
$secret->updateLastUsedAt();
$this->registry->getManager()->flush();
}

$event = $request->headers->get('X-GitHub-Event');
if ('ping' === $event) {
return new JsonResponse(['status' => 'success', 'message' => 'Webhook configured successfully']);
}
if ('push' !== $event) {
return new JsonResponse(
['status' => 'success', 'message' => sprintf('GitHub event "%s" ignored', $event ?? '')],
Response::HTTP_ACCEPTED,
);
}

$payload = $this->getJsonPayload($request);
if (!$payload) {
return new JsonResponse(
['status' => 'error', 'message' => 'Missing or invalid JSON payload'],
Response::HTTP_NOT_ACCEPTABLE,
);
}

$packages = PacketonUtils::findPackagesByPayload(
$payload,
$this->registry->getRepository(Package::class),
true,
);
if (!$packages) {
return new JsonResponse(
['status' => 'error', 'message' => 'No matching packages found'],
Response::HTTP_NOT_FOUND,
);
}

return $this->schedulePostJobs($packages);
}

#[Route('/api/packages/{name}', name: 'api_edit_package', requirements: ['name' => '%package_name_regex%'], methods: ['PUT'])]
public function editPackageAction(Request $request, #[Vars] Package $package): Response
{
Expand Down
76 changes: 76 additions & 0 deletions src/Controller/WebhookSecretController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace Packeton\Controller;

use Doctrine\Persistence\ManagerRegistry;
use Packeton\Attribute\Vars;
use Packeton\Entity\WebhookSecret;
use Packeton\Form\Type\WebhookSecretType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/webhook-secrets')]
#[IsGranted('ROLE_ADMIN')]
class WebhookSecretController extends AbstractController
{
public function __construct(
private readonly ManagerRegistry $registry,
) {
}

#[Route('', name: 'webhook_secret_index', methods: ['GET'])]
public function indexAction(): Response
{
return $this->render('webhook_secret/index.html.twig', [
'secrets' => $this->registry->getRepository(WebhookSecret::class)->findAllOrdered(),
]);
}

#[Route('/create', name: 'webhook_secret_create', methods: ['GET', 'POST'])]
public function createAction(Request $request): Response
{
$secret = (new WebhookSecret())->setSecret(WebhookSecret::generateSecret());
$form = $this->createForm(WebhookSecretType::class, $secret);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->registry->getManager();
$entityManager->persist($secret);
$entityManager->flush();

$response = $this->render('webhook_secret/show_secret.html.twig', [
'secret' => $secret,
'generatedSecret' => $secret->getSecret(),
]);
$response->setPrivate();
$response->headers->addCacheControlDirective('no-store');

return $response;
}

return $this->render('webhook_secret/create.html.twig', [
'form' => $form->createView(),
]);
}

#[Route('/{id}/delete', name: 'webhook_secret_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
public function deleteAction(Request $request, #[Vars] WebhookSecret $secret): Response
{
if (!$this->isCsrfTokenValid('webhook_secret_delete_'.$secret->getId(), $request->request->get('_token'))) {
return new Response('Invalid csrf token', Response::HTTP_BAD_REQUEST);
}

$entityManager = $this->registry->getManager();
$entityManager->remove($secret);
$entityManager->flush();

$this->addFlash('success', 'Webhook secret deleted.');

return $this->redirectToRoute('webhook_secret_index');
}
}
86 changes: 86 additions & 0 deletions src/Entity/WebhookSecret.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace Packeton\Entity;

use Doctrine\ORM\Mapping as ORM;
use Packeton\Repository\WebhookSecretRepository;

#[ORM\Entity(repositoryClass: WebhookSecretRepository::class)]
#[ORM\Table(name: 'webhook_secret')]
class WebhookSecret
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $name = null;

#[ORM\Column(type: 'encrypted_text')]
private ?string $secret = null;

#[ORM\Column(type: 'datetime')]
private \DateTimeInterface $createdAt;

#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $lastUsedAt = null;

public function __construct()
{
$this->createdAt = new \DateTime('now', new \DateTimeZone('UTC'));
}

public function getId(): ?int
{
return $this->id;
}

public function getName(): ?string
{
return $this->name;
}

public function setName(string $name): self
{
$this->name = $name;

return $this;
}

public function getSecret(): ?string
{
return $this->secret;
}

public function setSecret(string $secret): self
{
$this->secret = $secret;

return $this;
}

public function getCreatedAt(): \DateTimeInterface
{
return $this->createdAt;
}

public function getLastUsedAt(): ?\DateTimeInterface
{
return $this->lastUsedAt;
}

public function updateLastUsedAt(): self
{
$this->lastUsedAt = new \DateTime('now', new \DateTimeZone('UTC'));

return $this;
}

public static function generateSecret(): string
{
return bin2hex(random_bytes(32));
}
}
35 changes: 35 additions & 0 deletions src/Form/Type/WebhookSecretType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Packeton\Form\Type;

use Packeton\Entity\WebhookSecret;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

class WebhookSecretType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name', TextType::class, [
'label' => 'Name',
'help' => 'Use a descriptive name for the GitHub organization or webhook.',
'constraints' => [
new NotBlank(),
new Length(max: 255),
],
]);
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => WebhookSecret::class,
]);
}
}
1 change: 1 addition & 0 deletions src/Menu/MenuBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public function createAdminMenu()
$menu->addChild($this->translator->trans('menu.my_groups'), ['label' => 'menu.my_groups_icon', 'route' => 'groups_index', 'extras' => ['safe_label' => true]]);
$menu->addChild($this->translator->trans('menu.ssh_keys'), ['label' => 'menu.ssh_keys_icon', 'route' => 'user_add_sshkey', 'extras' => ['safe_label' => true]]);
$menu->addChild($this->translator->trans('menu.webhooks'), ['label' => 'menu.webhooks_icon', 'route' => 'webhook_index', 'extras' => ['safe_label' => true]]);
$menu->addChild($this->translator->trans('menu.webhook_secrets'), ['label' => 'menu.webhook_secrets_icon', 'route' => 'webhook_secret_index', 'extras' => ['safe_label' => true]]);
$menu->addChild($this->translator->trans('menu.proxies'), ['label' => 'menu.proxies_icon', 'route' => 'proxies_list', 'extras' => ['safe_label' => true]]);
$menu->addChild($this->translator->trans('menu.subrepository'), ['label' => 'menu.subrepository_icon', 'route' => 'subrepository_index', 'extras' => ['safe_label' => true]]);
if ($this->integrations->getNames()) {
Expand Down
Loading
Loading