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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ All notable changes to the API and the widget are documented here. The format fo

### Fixed

- Feedback links answered 403 in browsers behind a TLS terminating proxy (Laravel Cloud): the navigation guard checked the signature against the http:// URL before the trusted proxies were applied. The route's signature check stays authoritative.
- `<noscript>` content (e.g. of an Alpine toast region) showed up as literal markup in screenshots; `<noscript>` and `<template>` are now left out.
- Screenshots with the script tag from the API: the lazy screenshot chunk was served without `Access-Control-Allow-Origin`, so other websites could not load it.
- Screenshots of pages using Alpine, Vue or other framework attributes (`:class`, `x-on:click`) were blank.
Expand Down
7 changes: 5 additions & 2 deletions app/Http/Middleware/BlockBrowserNavigation.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
/**
* The API is only for the widget (CORS requests) and for monitoring. Typing
* a URL into a browser is a navigation and gets an empty 403. The only
* exception are valid signed links from feedback:link.
* exception are signed feedback links (feedback:link, share link): their
* signature is checked by the route's "signed" middleware, which runs after
* the trusted proxies are applied. Here, before that, the URL would still be
* http:// behind a TLS terminating proxy and no signature would match.
*/
final class BlockBrowserNavigation
{
Expand All @@ -21,7 +24,7 @@ public function handle(Request $request, Closure $next): Response
{
$navigation = $request->headers->get('Sec-Fetch-Mode') === 'navigate' || $request->headers->get('Sec-Fetch-Dest') === 'document';

if ($navigation && ! $request->hasValidSignature()) {
if ($navigation && ! ($request->is('v1/feedback/*') && $request->query->has('signature'))) {
return new IlluminateResponse('', 403);
}

Expand Down
19 changes: 19 additions & 0 deletions tests/Feature/FeedbackLinkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Models\FeedbackProject;
use App\Models\FeedbackScreenshot;
use App\Support\FeedbackLink;
use Illuminate\Http\Middleware\TrustProxies;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
Expand Down Expand Up @@ -276,3 +277,21 @@ function entryWithShot(FeedbackProject $project, array $attributes = []): Feedba
test()->post($action, ['status' => 'completed'])->assertForbidden();
expect($entry->refresh()->completed_at)->toBeNull();
});

it('opens links behind a TLS terminating proxy (production)', function (): void {
// Laravel Cloud: the link is https, the app sees http plus X-Forwarded-Proto.
TrustProxies::at('*');
URL::forceScheme('https');
$this->beforeApplicationDestroyed(function (): void {
TrustProxies::flushState();
URL::forceScheme(null);
});
$entry = FeedbackEntry::factory()->for($this->project, 'project')->create();
$url = feedbackLink($entry->id)['url'];
expect($url)->toStartWith('https://');
$behindProxy = ['X-Forwarded-Proto' => 'https', 'X-Forwarded-Port' => '443', ...NAVIGATE];

test()->get(str_replace('https://', 'http://', $url), $behindProxy)->assertOk()->assertSee($entry->message);
test()->get(str_replace('https://', 'http://', str_replace('signature=', 'signature=0', $url)), $behindProxy)->assertForbidden()->assertContent('');
test()->get(str_replace('https://', 'http://', url('/up')).'?signature=x', $behindProxy)->assertForbidden()->assertContent('');
});
Loading