diff --git a/examples/symfony/Dockerfile b/examples/symfony/Dockerfile index 102eb59..6003081 100644 --- a/examples/symfony/Dockerfile +++ b/examples/symfony/Dockerfile @@ -3,7 +3,7 @@ FROM php:8.4-cli RUN apt-get update && apt-get install -y --no-install-recommends git unzip \ && rm -rf /var/lib/apt/lists/* \ - && printf "upload_max_filesize=10M\npost_max_size=12M\ndisplay_errors=Off\nlog_errors=On\n" > /usr/local/etc/php/conf.d/uploads.ini + && printf "upload_max_filesize=100M\npost_max_size=105M\ndisplay_errors=Off\nlog_errors=On\n" > /usr/local/etc/php/conf.d/uploads.ini COPY --from=composer:2 /usr/bin/composer /usr/bin/composer # The SDK from this repository. Once published, `composer require attachmentav/virus-scan-sdk` is enough. @@ -24,7 +24,9 @@ COPY examples/symfony/templates templates COPY examples/symfony/config config ENV APP_ENV=prod -RUN composer dump-env prod && php bin/console cache:clear +# APP_SECRET signs the download URLs of the async scan. Generated once per image build. +RUN echo "APP_SECRET=$(php -r 'echo bin2hex(random_bytes(16));')" > .env.prod.local \ + && composer dump-env prod && php bin/console cache:clear EXPOSE 8000 # PHP's built-in web server is fine for this example; use nginx/php-fpm or FrankenPHP in production. diff --git a/examples/symfony/README.md b/examples/symfony/README.md index 3644144..4635797 100644 --- a/examples/symfony/README.md +++ b/examples/symfony/README.md @@ -1,6 +1,9 @@ # attachmentAV SDK: Symfony example -A minimal Symfony app with a file upload form. The uploaded file is scanned with the attachmentAV SDK, and the page shows the scan result. +A minimal Symfony app with two file upload forms: + +* **Sync scan** (`/`): the uploaded file is scanned with the attachmentAV SDK, and the page shows the scan result. +* **Async scan** (`/documents`, SaaS only): the uploaded file is kept in a staging directory. attachmentAV downloads the file through a signed URL, scans it in the background, and sends the scan result to a callback URL. ## Run @@ -19,8 +22,34 @@ Open http://localhost:8001 and upload a file. To see a detection, upload the [EI The image creates a fresh Symfony project, installs the SDK from this repository, and copies the example files into it. It uses PHP's built-in web server, which is fine for trying the example but not for production. +## Run the async scan + +attachmentAV must reach the app from the Internet to download the file and to send the callback. Use a tunnel such as [ngrok](https://ngrok.com/). + +First, get your tenant ID. It is required to verify the signatures of callbacks. + +```bash +ATTACHMENTAV_API_KEY= ATTACHMENTAV_REGION=eu docker compose run --rm --build app php -r \ + 'require "vendor/autoload.php"; echo (new AttachmentAV\AttachmentAVApi(AttachmentAV\Configuration::saas(getenv("ATTACHMENTAV_API_KEY"), getenv("ATTACHMENTAV_REGION"))))->whoami()->getTenantId(), PHP_EOL;' +``` + +Second, start the app with the tenant ID, and open a tunnel in a second terminal. + +```bash +ATTACHMENTAV_API_KEY= ATTACHMENTAV_REGION=eu ATTACHMENTAV_TENANT_ID= docker compose up --build + +ngrok http 8001 +``` + +Third, open `https://.ngrok-free.app/documents`, not `localhost`: the download and callback URLs are generated from the URL of the page. Upload a file (max. 100 MB). The result page refreshes until the callback has arrived. Clean files are moved to `var/documents`, all other files are deleted. + +The example trusts the `X-Forwarded-*` headers of whoever connects, so that Symfony generates the public HTTPS URLs behind the tunnel. In production, set `TRUSTED_PROXIES` to the IP addresses of your reverse proxies. + ## Files -* [`config/packages/attachmentav.yaml`](config/packages/attachmentav.yaml): registers `AttachmentAV\AttachmentAVApi` as a service, configured from environment variables. -* [`src/Controller/ScanController.php`](src/Controller/ScanController.php): checks the CSRF token and the upload, then calls `AttachmentAVApi::scanSyncFile()`. -* [`templates/scan.html.twig`](templates/scan.html.twig): the upload form and the scan result. +* [`config/packages/attachmentav.yaml`](config/packages/attachmentav.yaml): registers `AttachmentAV\AttachmentAVApi` and `AttachmentAV\Callback\CallbackVerifier` as services, configured from environment variables. +* [`config/packages/trusted_proxies.yaml`](config/packages/trusted_proxies.yaml): trusts the `X-Forwarded-*` headers of the tunnel or reverse proxy. +* [`src/Controller/ScanController.php`](src/Controller/ScanController.php): sync scan. Checks the CSRF token and the upload, then calls `AttachmentAVApi::scanSyncFile()`. +* [`src/Controller/DocumentController.php`](src/Controller/DocumentController.php): async scan. Moves the upload to the staging directory, signs a download URL with Symfony's `UriSigner`, calls `AttachmentAVApi::scanAsyncDownload()`, and serves the staged file to attachmentAV. +* [`src/Controller/AttachmentAVCallbackController.php`](src/Controller/AttachmentAVCallbackController.php): verifies the callback with `CallbackVerifier`, then moves the clean file out of the staging directory or deletes it. +* [`templates/`](templates): the upload forms and the scan results. diff --git a/examples/symfony/compose.yaml b/examples/symfony/compose.yaml index 717882d..b31037f 100644 --- a/examples/symfony/compose.yaml +++ b/examples/symfony/compose.yaml @@ -11,3 +11,5 @@ services: # Self-hosted on AWS: set ATTACHMENTAV_DEPLOYMENT=self-hosted and ATTACHMENTAV_BASE_URL ATTACHMENTAV_DEPLOYMENT: ${ATTACHMENTAV_DEPLOYMENT:-saas} ATTACHMENTAV_BASE_URL: ${ATTACHMENTAV_BASE_URL:-} + # Async scans (SaaS): required to verify callbacks + ATTACHMENTAV_TENANT_ID: ${ATTACHMENTAV_TENANT_ID:-} diff --git a/examples/symfony/config/packages/attachmentav.yaml b/examples/symfony/config/packages/attachmentav.yaml index a40aad3..6dd260a 100644 --- a/examples/symfony/config/packages/attachmentav.yaml +++ b/examples/symfony/config/packages/attachmentav.yaml @@ -13,6 +13,13 @@ services: arguments: $configuration: '@AttachmentAV\Configuration' + # Verifies the signatures of callbacks (async scans). Get your tenant ID with AttachmentAVApi::whoami(). + AttachmentAV\Callback\CallbackVerifier: + factory: ['AttachmentAV\Callback\CallbackVerifier', 'forRegion'] + arguments: + $region: '%env(default:attachmentav_default_region:ATTACHMENTAV_REGION)%' + $tenantId: '%env(ATTACHMENTAV_TENANT_ID)%' + parameters: attachmentav_default_deployment: 'saas' attachmentav_default_region: 'eu' diff --git a/examples/symfony/config/packages/trusted_proxies.yaml b/examples/symfony/config/packages/trusted_proxies.yaml new file mode 100644 index 0000000..c873f75 --- /dev/null +++ b/examples/symfony/config/packages/trusted_proxies.yaml @@ -0,0 +1,10 @@ +# The download and callback URLs sent to attachmentAV are generated from the incoming request. +# Behind a tunnel such as ngrok or a reverse proxy, Symfony must trust its X-Forwarded-* headers +# to generate the public HTTPS URLs. This example trusts whoever connects (REMOTE_ADDR), which is fine +# locally. In production, set TRUSTED_PROXIES to the IP addresses of your proxies. +framework: + trusted_proxies: '%env(default:trusted_proxies_default:TRUSTED_PROXIES)%' + trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port'] + +parameters: + trusted_proxies_default: 'REMOTE_ADDR' diff --git a/examples/symfony/src/Controller/AttachmentAVCallbackController.php b/examples/symfony/src/Controller/AttachmentAVCallbackController.php new file mode 100644 index 0000000..5d9f3c4 --- /dev/null +++ b/examples/symfony/src/Controller/AttachmentAVCallbackController.php @@ -0,0 +1,70 @@ +verifier->verifyAndParse( + $request->getContent(), + $request->headers->get('X-Signature'), + $request->headers->get('X-Timestamp'), + $this->generateUrl('attachmentav_callback', [], UrlGeneratorInterface::ABSOLUTE_URL) + ); + } catch (InvalidSignatureException) { + return new Response('', Response::HTTP_FORBIDDEN); + } + + // The signature covers the custom data, so it is exactly what DocumentController sent. + $data = json_decode((string) $payload->getCustomData(), true); + $staged = $this->stagingDir . '/' . $data['id']; + + // attachmentAV may deliver a callback more than once. + if (!is_file($staged)) { + return new Response('', Response::HTTP_NO_CONTENT); + } + + if ($payload->isClean()) { + $this->filesystem->mkdir($this->documentsDir); + $this->filesystem->rename($staged, $this->documentsDir . '/' . $data['id']); + } else { + // 'infected' or 'no' (could not be scanned) + $this->filesystem->remove($staged); + } + + $this->filesystem->dumpFile($this->resultsDir . '/' . $data['id'] . '.json', json_encode([ + 'filename' => $data['filename'], + 'status' => $payload->getStatus(), + 'finding' => $payload->getFinding(), + 'size' => $payload->getSize(), + 'realFileType' => $payload->getRealFileType(), + ])); + + return new Response('', Response::HTTP_NO_CONTENT); + } +} diff --git a/examples/symfony/src/Controller/DocumentController.php b/examples/symfony/src/Controller/DocumentController.php new file mode 100644 index 0000000..e31cda0 --- /dev/null +++ b/examples/symfony/src/Controller/DocumentController.php @@ -0,0 +1,120 @@ +attachmentAV->getConfiguration()->isSaas()) { + return $this->render('documents.html.twig', ['error' => 'Async scans are available for the attachmentAV API (SaaS) only.']); + } + + // The download and callback URLs are generated from the URL of this page and must be reachable from the Internet. + if (in_array($request->getHost(), ['localhost', '127.0.0.1'], true)) { + return $this->render('documents.html.twig', ['error' => 'attachmentAV cannot reach localhost. Open this page through a public URL, e.g. with ngrok (see README).']); + } + + if (!$request->isMethod('POST')) { + return $this->render('documents.html.twig'); + } + + // PHP discards the whole request body if it exceeds post_max_size, including the CSRF token. + if ($request->request->count() === 0 && $request->files->count() === 0 && (int) $request->server->get('CONTENT_LENGTH') > 0) { + return $this->render('documents.html.twig', ['error' => 'The file must not be larger than 100 MB.'], new Response('', 413)); + } + + if (!$this->isCsrfTokenValid('documents', (string) $request->request->get('_token'))) { + return $this->render('documents.html.twig', ['error' => 'Invalid CSRF token, please try again.'], new Response('', 400)); + } + + $file = $request->files->get('file'); + if (!$file instanceof UploadedFile || !$file->isValid()) { + $message = $file instanceof UploadedFile ? $file->getErrorMessage() : 'Please select a file.'; + + return $this->render('documents.html.twig', ['error' => $message], new Response('', 422)); + } + + $filename = $file->getClientOriginalName(); + $id = bin2hex(random_bytes(16)); + $file->move($this->stagingDir, $id); + $this->filesystem->dumpFile($this->resultsDir . '/' . $id . '.json', json_encode(['filename' => $filename, 'status' => 'pending'])); + + // attachmentAV downloads the file from this URL. The signature expires after one hour. + $downloadUrl = $this->uriSigner->sign( + $this->generateUrl('document_staging', ['id' => $id], UrlGeneratorInterface::ABSOLUTE_URL), + new \DateInterval('PT1H') + ); + + try { + $this->attachmentAV->scanAsyncDownload( + $downloadUrl, + callbackUrl: $this->generateUrl('attachmentav_callback', [], UrlGeneratorInterface::ABSOLUTE_URL), + customData: json_encode(['id' => $id, 'filename' => $filename]) + ); + } catch (ExceptionInterface $e) { + $this->filesystem->remove([$this->stagingDir . '/' . $id, $this->resultsDir . '/' . $id . '.json']); + + return $this->render('documents.html.twig', [ + 'filename' => $filename, + 'error' => $e->getMessage(), + ], new Response('', 502)); + } + + return $this->redirectToRoute('document_show', ['id' => $id]); + } + + #[Route('/documents/{id}', name: 'document_show', methods: ['GET'], requirements: ['id' => '[0-9a-f]{32}'])] + public function show(string $id): Response + { + $path = $this->resultsDir . '/' . $id . '.json'; + if (!is_file($path)) { + throw $this->createNotFoundException(); + } + + return $this->render('document.html.twig', ['document' => json_decode((string) file_get_contents($path), true)]); + } + + #[Route('/documents/staging/{id}', name: 'document_staging', methods: ['GET'], requirements: ['id' => '[0-9a-f]{32}'])] + public function staging(Request $request, string $id): Response + { + if (!$this->uriSigner->checkRequest($request)) { + throw $this->createAccessDeniedException(); + } + + $path = $this->stagingDir . '/' . $id; + if (!is_file($path)) { + throw $this->createNotFoundException(); + } + + // attachmentAV detects the real file type itself, no need to guess the MIME type. + return new BinaryFileResponse($path, headers: ['Content-Type' => 'application/octet-stream']); + } +} diff --git a/examples/symfony/templates/document.html.twig b/examples/symfony/templates/document.html.twig new file mode 100644 index 0000000..a72ac0e --- /dev/null +++ b/examples/symfony/templates/document.html.twig @@ -0,0 +1,37 @@ + + + + + + {% if document.status == 'pending' %}{% endif %} + attachmentAV – Symfony example (async) + + + +

Async virus and malware scan

+ +
+
+
File
{{ document.filename }}
+
Status
{{ document.status == 'pending' ? 'pending, waiting for the callback …' : document.status }}
+ {% if document.finding is defined and document.finding %} +
Finding
{{ document.finding }}
+ {% endif %} + {% if document.size is defined %} +
Size
{{ document.size }} bytes
+
Real file type
{{ document.realFileType ?? 'unknown' }}
+ {% endif %} +
+
+ +

Upload another file

+ + diff --git a/examples/symfony/templates/documents.html.twig b/examples/symfony/templates/documents.html.twig new file mode 100644 index 0000000..3caca9d --- /dev/null +++ b/examples/symfony/templates/documents.html.twig @@ -0,0 +1,33 @@ + + + + + + attachmentAV – Symfony example (async) + + + +

Async virus and malware scan

+

Upload a file (max. 100 MB). attachmentAV downloads it from the staging directory, scans it in the background, and sends the scan result to the callback URL.

+ +
+ + + +
+ + {% if error is defined %} +
+ {% if filename is defined %}{{ filename }} could not be submitted: {% endif %}{{ error }} +
+ {% endif %} + +

Sync scan

+ + diff --git a/examples/symfony/templates/scan.html.twig b/examples/symfony/templates/scan.html.twig index eda07e5..657af1b 100644 --- a/examples/symfony/templates/scan.html.twig +++ b/examples/symfony/templates/scan.html.twig @@ -44,5 +44,7 @@ {% endif %} + +

Async scan