From 5227b54eac8ddbcce271db1a6b5247ff460705bf Mon Sep 17 00:00:00 2001 From: gakigaki Date: Mon, 30 Mar 2026 18:26:42 +0900 Subject: [PATCH 1/3] fix(core): validate inherited common-area frames in ConnectPage --- app/Http/Middleware/ConnectPage.php | 105 +++++++- app/Models/Common/Page.php | 8 + .../ConnectPageFrameValidationFeatureTest.php | 240 ++++++++++++++++++ 3 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/Core/ConnectPageFrameValidationFeatureTest.php diff --git a/app/Http/Middleware/ConnectPage.php b/app/Http/Middleware/ConnectPage.php index 41c3056c0..8d9247e6f 100644 --- a/app/Http/Middleware/ConnectPage.php +++ b/app/Http/Middleware/ConnectPage.php @@ -15,6 +15,7 @@ use App\Models\Common\PageRole; use App\Models\Common\Permalink; use App\Models\Migration\MigrationMapping; +use App\Enums\AreaType; class ConnectPage { @@ -155,6 +156,9 @@ public function handle($request, Closure $next) $page_tree->push($top_page); } + // 403 判定で共通エリアフレームを辿るため、ページツリーに紐づくフレームをまとめて取得して N+1 を避ける。 + $this->loadCommonAreaFrames($page_tree); + // 現在のページが参照可能か判定して、NG なら403 ページを振り向ける。 // (ページがある(管理画面ではページがない)&IP制限がかかっていない場合は参照OK) // HTTP ステータスコード(null なら200) @@ -392,7 +396,7 @@ private function checkPageForbidden($request, $page_tree, $router) } // page_id と frame_id の組み合わせが不整合なら、不正アクセスとして 403 扱いにする。 - if (!$this->isValidPageAndFrame($request)) { + if (!$this->isValidPageAndFrame($request, $page_tree)) { return $this->doForbidden(); } @@ -430,28 +434,117 @@ private function isPageLimitCheckRoute($route_name) /** * page_id と frame_id の整合性判定 */ - private function isValidPageAndFrame($request) + private function isValidPageAndFrame($request, $page_tree) { $route_page_id = $request->route('page_id'); $route_frame_id = $request->route('frame_id'); - // frame_id がなければ判定不要 + // frame_id を伴わないルートは、ページとフレームの組み合わせ判定自体が不要。 if (empty($route_frame_id)) { return true; } - // frame_id があるのに page_id がない場合は不正 + // frame_id があるのに page_id がない組み合わせは、対象ページを特定できないので不正扱い。 if (empty($route_page_id)) { return false; } - // frameはhandle()で事前に取得済み + // frame は handle() で先に読み込んでいる前提。取得できない frame_id は不正扱い。 $frame = $request->attributes->get('frame'); if (empty($frame)) { return false; } - return ((int)$frame->page_id === (int)$route_page_id); + // メインエリアは継承しないため、配置ページと現在ページが完全一致する場合だけ許可する。 + if ((int)$frame->area_id === AreaType::main) { + return ((int)$frame->page_id === (int)$route_page_id); + } + + // 共通エリアは「現在ページの祖先ツリー上で実際に採用されるフレームか」を判定する。 + // そのため、現在ページと祖先ツリーが解決できない場合は許可できない。 + if (empty($page_tree) || empty($this->page) || empty($this->page->id)) { + return false; + } + + // 表示中ページから親へ辿ったとき、この共通エリアで最初に有効になる配置ページを求める。 + $effective_page_id = $this->getEffectiveCommonAreaPageId($page_tree, (int)$route_page_id, (int)$frame->area_id); + if (empty($effective_page_id)) { + return false; + } + + // 要求された frame の配置ページが、実際にこの画面で採用される継承元ページと一致するときだけ許可する。 + return ((int)$effective_page_id === (int)$frame->page_id); + } + + /** + * ページツリーに紐づく共通エリアフレームを一括取得 + */ + private function loadCommonAreaFrames($page_tree): void + { + if (empty($page_tree)) { + return; + } + + $page_tree->load(['frames' => function ($query) { + $query->select(['id', 'page_id', 'area_id', 'page_only']) + ->where('area_id', '!=', AreaType::main) + ->orderBy('display_sequence', 'asc'); + }]); + } + + /** + * 共通エリアフレームの有効な継承元ページIDを取得 + */ + private function getEffectiveCommonAreaPageId($page_tree, int $current_page_id, int $area_id): ?int + { + // page_tree は表示中ページ→親→祖先の順なので、最初に見つかったページが実際に採用される継承元になる。 + foreach ($page_tree as $page) { + if (empty($page) || empty($page->id)) { + continue; + } + + if ($this->hasCommonAreaFrames($page, $current_page_id, $area_id)) { + return (int)$page->id; + } + } + + return null; + } + + /** + * 共通エリアフレームを持つか + */ + private function hasCommonAreaFrames(Page $page, int $current_page_id, int $area_id): bool + { + // page_tree に対して frames を eager load 済みなら、追加クエリを打たずにメモリ上で判定する。 + if ($page->relationLoaded('frames')) { + return $page->frames->contains(function ($frame) use ($page, $current_page_id, $area_id) { + if ((int)$frame->area_id !== $area_id) { + return false; + } + + return (int)$frame->page_only === 0 + || ((int)$frame->page_only === 1 && (int)$page->id === $current_page_id) + || (int)$frame->page_only === 2; + }); + } + + // relation 未ロードの経路でも同じ条件で判定できるよう、フォールバックする。 + return Frame::where('page_id', $page->id) + ->where('area_id', $area_id) + ->where(function ($query) use ($current_page_id) { + // 共通エリアの取得条件は DefaultController と揃える。 + // page_only=0: 常に継承候補 + // page_only=1: 配置ページ本人のときだけ継承候補 + // page_only=2: 配置ページ本人では非表示でも、子ページでは継承候補 + $query->where('page_only', 0) + ->orWhere(function ($query2) use ($current_page_id) { + $query2->where('page_only', 1) + ->where('page_id', $current_page_id); + }) + ->orWhere('page_only', 2); + }) + ->exists(); } /** diff --git a/app/Models/Common/Page.php b/app/Models/Common/Page.php index 548f17567..ce992b3a2 100644 --- a/app/Models/Common/Page.php +++ b/app/Models/Common/Page.php @@ -58,6 +58,14 @@ public function page_roles() // phpcs:ignore return $this->hasMany(PageRole::class); } + /** + * hasMany 設定 + */ + public function frames() + { + return $this->hasMany(Frame::class); + } + /** * 言語設定があれば、特定の言語ページのみに絞る */ diff --git a/tests/Feature/Core/ConnectPageFrameValidationFeatureTest.php b/tests/Feature/Core/ConnectPageFrameValidationFeatureTest.php new file mode 100644 index 000000000..75410eb13 --- /dev/null +++ b/tests/Feature/Core/ConnectPageFrameValidationFeatureTest.php @@ -0,0 +1,240 @@ + '403', + 'permanent_link' => '/403', + 'base_display_flag' => 1, + ]); + } + + /** + * ページツリー構築用に、必要最小限のページを1件生成する。 + */ + private function createPage(string $page_name, string $permanent_link, ?Page $parent = null): Page + { + $page = new Page([ + 'page_name' => $page_name, + 'permanent_link' => $permanent_link, + 'base_display_flag' => 1, + ]); + + if (is_null($parent)) { + $page->save(); + } else { + $page->appendToNode($parent)->save(); + } + + return $page->fresh(); + } + + /** + * 祖先継承の組み合わせを検証するための標準的なページツリーを生成する。 + */ + private function createPageTree(): array + { + $root = $this->createPage('top', '/'); + $parent = $this->createPage('parent', '/parent', $root); + $child = $this->createPage('child', '/parent/child', $parent); + $sibling = $this->createPage('sibling', '/sibling', $root); + + return [$root, $parent, $child, $sibling]; + } + + /** + * フレーム判定に必要な属性だけを持つ contents フレームを生成する。 + */ + private function createContentsFrame(Page $page, int $area_id, array $attributes = []): Frame + { + $bucket = Buckets::factory()->create([ + 'plugin_name' => 'contents', + ]); + + return Frame::create(array_merge([ + 'page_id' => $page->id, + 'area_id' => $area_id, + 'frame_title' => 'test frame', + 'frame_design' => 'default', + 'plugin_name' => 'contents', + 'frame_col' => 12, + 'template' => 'default', + 'plug_name' => null, + 'bucket_id' => $bucket->id, + 'display_sequence' => 1, + 'browser_width' => null, + 'disable_whatsnews' => 0, + 'disable_searchs' => 0, + 'page_only' => 0, + 'default_hidden' => 0, + 'classname' => '', + 'classname_body' => '', + 'none_hidden' => 0, + 'content_open_type' => ContentOpenType::always_open, + 'content_open_date_from' => null, + 'content_open_date_to' => null, + ], $attributes)); + } + + /** + * handle() が参照する route / request 情報を、隔離した Laravel Router 上で組み立てる。 + */ + private function createRequest(int $page_id, ?int $frame_id = null): Request + { + $request = Request::create( + is_null($frame_id) ? "/test/{$page_id}" : "/test/{$page_id}/{$frame_id}", + 'GET' + ); + $request->attributes->set('configs', collect([ + new Configs([ + 'name' => 'page_permanent_link_403', + 'category' => 'page_error', + 'value' => '/403', + ]), + ])); + + // アプリ本体の catch-all ルートには依存せず、Laravel 標準の Router / Route で route 解決だけ行う。 + $router = new Router($this->app['events'], $this->app); + $router->get('/test/{page_id}/{frame_id?}', function () { + return response('prepared', 200); + })->name('get_plugin'); + $router->dispatchToRoute($request); + + $this->app->instance('request', $request); + $this->app->instance(Request::class, $request); + $this->app->instance(Router::class, $router); + + return $request; + } + + /** + * handle() を通し、middleware の公開振る舞いとして結果を取得する。 + */ + private function handleRequest(Request $request) + { + $middleware = new ConnectPage(); + + return $middleware->handle($request, function ($handled_request) { + $http_status_code = $handled_request->attributes->get('http_status_code', 200); + return response('ok', $http_status_code); + }); + } + + /** + * テストの意図: + * 子ページ表示時に親ページ配置の共通エリアフレームが有効なら、公開入口 handle() は操作を許可する。 + */ + public function testAncestorCommonAreaFrameCanBeAccessedFromDescendantPage(): void + { + [, $parent, $child] = $this->createPageTree(); + $frame = $this->createContentsFrame($parent, AreaType::header); + + $response = $this->handleRequest($this->createRequest($child->id, $frame->id)); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('ok', $response->getContent()); + } + + /** + * テストの意図: + * page_only=2 の共通エリアフレームは配置ページ自身では非表示でも、子ページでは継承対象として操作可能なことを守る。 + */ + public function testAncestorCommonAreaFrameWithPageOnlyHiddenCanBeAccessedFromDescendantPage(): void + { + [, $parent, $child] = $this->createPageTree(); + $frame = $this->createContentsFrame($parent, AreaType::header, [ + 'page_only' => 2, + ]); + + $response = $this->handleRequest($this->createRequest($child->id, $frame->id)); + + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * テストの意図: + * page_only=1 の共通エリアフレームは配置ページ本人専用であり、子ページからは操作できないことを守る。 + */ + public function testAncestorCommonAreaFrameWithPageOnlyCurrentIsForbiddenFromDescendantPage(): void + { + [, $parent, $child] = $this->createPageTree(); + $frame = $this->createContentsFrame($parent, AreaType::header, [ + 'page_only' => 1, + ]); + + $response = $this->handleRequest($this->createRequest($child->id, $frame->id)); + + $this->assertSame(403, $response->getStatusCode()); + } + + /** + * テストの意図: + * メインエリアは継承しないため、親ページ配置のフレームを子ページから操作できないことを守る。 + */ + public function testAncestorMainAreaFrameIsForbiddenFromDescendantPage(): void + { + [, $parent, $child] = $this->createPageTree(); + $frame = $this->createContentsFrame($parent, AreaType::main); + + $response = $this->handleRequest($this->createRequest($child->id, $frame->id)); + + $this->assertSame(403, $response->getStatusCode()); + } + + /** + * テストの意図: + * 現在ページ系統に属さない別ページの共通エリアフレームは、不正な組み合わせとして拒否することを守る。 + */ + public function testUnrelatedPageFrameIsForbidden(): void + { + [, , $child, $sibling] = $this->createPageTree(); + $frame = $this->createContentsFrame($sibling, AreaType::header); + + $response = $this->handleRequest($this->createRequest($child->id, $frame->id)); + + $this->assertSame(403, $response->getStatusCode()); + } + + /** + * テストの意図: + * 共通エリアは最も近い祖先の配置が優先され、より遠い祖先のフレームは子ページから操作できないことを守る。 + */ + public function testMoreDistantAncestorCommonAreaFrameIsForbiddenWhenCloserAncestorOverrides(): void + { + [$root, $parent, $child] = $this->createPageTree(); + $root_frame = $this->createContentsFrame($root, AreaType::header); + $this->createContentsFrame($parent, AreaType::header); + + $response = $this->handleRequest($this->createRequest($child->id, $root_frame->id)); + + $this->assertSame(403, $response->getStatusCode()); + } +} From 114da808505f80b3e6d34b68b98edc6f3d5c876e Mon Sep 17 00:00:00 2001 From: gakigaki Date: Tue, 31 Mar 2026 18:37:11 +0900 Subject: [PATCH 2/3] fix(core): tighten inherited common area frame validation --- app/Http/Middleware/ConnectPage.php | 261 ++++++++++++++---- .../ConnectPageFrameValidationFeatureTest.php | 36 +++ 2 files changed, 246 insertions(+), 51 deletions(-) diff --git a/app/Http/Middleware/ConnectPage.php b/app/Http/Middleware/ConnectPage.php index 8d9247e6f..81a202e59 100644 --- a/app/Http/Middleware/ConnectPage.php +++ b/app/Http/Middleware/ConnectPage.php @@ -5,6 +5,7 @@ use Closure; use Illuminate\Routing\Router; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\View; @@ -156,9 +157,6 @@ public function handle($request, Closure $next) $page_tree->push($top_page); } - // 403 判定で共通エリアフレームを辿るため、ページツリーに紐づくフレームをまとめて取得して N+1 を避ける。 - $this->loadCommonAreaFrames($page_tree); - // 現在のページが参照可能か判定して、NG なら403 ページを振り向ける。 // (ページがある(管理画面ではページがない)&IP制限がかかっていない場合は参照OK) // HTTP ステータスコード(null なら200) @@ -460,91 +458,252 @@ private function isValidPageAndFrame($request, $page_tree) return ((int)$frame->page_id === (int)$route_page_id); } + // 共通エリアでも、配置ページ本人からの操作は従来通り許可する。 + if ((int)$frame->page_id === (int)$route_page_id) { + return true; + } + // 共通エリアは「現在ページの祖先ツリー上で実際に採用されるフレームか」を判定する。 // そのため、現在ページと祖先ツリーが解決できない場合は許可できない。 if (empty($page_tree) || empty($this->page) || empty($this->page->id)) { return false; } - // 表示中ページから親へ辿ったとき、この共通エリアで最初に有効になる配置ページを求める。 - $effective_page_id = $this->getEffectiveCommonAreaPageId($page_tree, (int)$route_page_id, (int)$frame->area_id); - if (empty($effective_page_id)) { - return false; + $effective_frames = $this->getEffectiveCommonAreaFrames($page_tree, (int)$frame->area_id); + + return $effective_frames->contains(function ($effective_frame) use ($frame) { + return (int)($effective_frame->frame_id ?? $effective_frame->id) === (int)$frame->id; + }); + } + + /** + * 現ページで実際に採用される共通エリアフレームを取得する。 + */ + private function getEffectiveCommonAreaFrames($page_tree, int $area_id): Collection + { + if (empty($this->page) || empty($this->page->id)) { + return collect(); + } + + $normalized_page_tree = $this->normalizePageTreeForCommonArea($page_tree); + if ($normalized_page_tree->isEmpty()) { + return collect(); } - // 要求された frame の配置ページが、実際にこの画面で採用される継承元ページと一致するときだけ許可する。 - return ((int)$effective_page_id === (int)$frame->page_id); + if (!$this->isCommonAreaVisibleOnCurrentPage($normalized_page_tree, $area_id)) { + return collect(); + } + + $page_ids = $normalized_page_tree->pluck('id')->filter()->all(); + if (empty($page_ids)) { + return collect(); + } + + $effective_page_id = null; + $effective_frames = collect(); + foreach ($this->queryCommonAreaFrames($page_ids, $area_id) as $frame) { + if (is_null($effective_page_id)) { + $effective_page_id = (int)$frame->page_id; + } + + if ((int)$frame->page_id !== $effective_page_id) { + break; + } + + $effective_frames->push($frame); + } + + return $effective_frames; } /** - * ページツリーに紐づく共通エリアフレームを一括取得 + * 共通エリア判定用にページツリーを正規化する。 */ - private function loadCommonAreaFrames($page_tree): void + private function normalizePageTreeForCommonArea($page_tree): Collection { if (empty($page_tree)) { - return; + return collect(); } - $page_tree->load(['frames' => function ($query) { - $query->select(['id', 'page_id', 'area_id', 'page_only']) - ->where('area_id', '!=', AreaType::main) - ->orderBy('display_sequence', 'asc'); - }]); + $normalized_page_tree = collect($page_tree->all()); + $top_page = Page::getTopPage(); + $language_top_page = $this->getLanguageTopPage(); + + $excluded_page_ids = collect([ + $top_page->id ?? null, + $language_top_page->id ?? null, + ])->filter()->map(function ($page_id) { + return (int)$page_id; + })->all(); + + $normalized_page_tree = $normalized_page_tree->filter(function ($tree_page) use ($excluded_page_ids) { + return !empty($tree_page) + && !empty($tree_page->id) + && !in_array((int)$tree_page->id, $excluded_page_ids, true); + })->values(); + + if (!empty($language_top_page) && !empty($language_top_page->id)) { + $normalized_page_tree->push($language_top_page); + } + + if (!empty($top_page) && !empty($top_page->id)) { + if (empty($language_top_page) || (int)$language_top_page->id !== (int)$top_page->id) { + $normalized_page_tree->push($top_page); + } + } + + return $normalized_page_tree; } /** - * 共通エリアフレームの有効な継承元ページIDを取得 + * 現在ページで共通エリアが描画対象か判定する。 */ - private function getEffectiveCommonAreaPageId($page_tree, int $current_page_id, int $area_id): ?int + private function isCommonAreaVisibleOnCurrentPage(Collection $page_tree, int $area_id): bool { - // page_tree は表示中ページ→親→祖先の順なので、最初に見つかったページが実際に採用される継承元になる。 - foreach ($page_tree as $page) { - if (empty($page) || empty($page->id)) { - continue; - } + $layout_array = $this->getLayoutArrayForCommonArea($page_tree); - if ($this->hasCommonAreaFrames($page, $current_page_id, $area_id)) { - return (int)$page->id; - } + if ($area_id === AreaType::header) { + return $layout_array[0] == '1'; + } + if ($area_id === AreaType::left) { + return $layout_array[1] == '1'; + } + if ($area_id === AreaType::right) { + return $layout_array[2] == '1'; + } + if ($area_id === AreaType::footer) { + return $layout_array[3] == '1'; } - return null; + return false; } /** - * 共通エリアフレームを持つか + * 共通エリア判定用のレイアウト配列を取得する。 */ - private function hasCommonAreaFrames(Page $page, int $current_page_id, int $area_id): bool + private function getLayoutArrayForCommonArea(Collection $page_tree): array { - // page_tree に対して frames を eager load 済みなら、追加クエリを打たずにメモリ上で判定する。 - if ($page->relationLoaded('frames')) { - return $page->frames->contains(function ($frame) use ($page, $current_page_id, $area_id) { - if ((int)$frame->area_id !== $area_id) { - return false; - } + $layout_array = explode('|', $this->getLayoutForCommonArea($page_tree)); + if (count($layout_array) !== 4) { + return [1, 1, 1, 1]; + } + + return $layout_array; + } + + /** + * 共通エリア判定用のレイアウトを取得する。 + */ + private function getLayoutForCommonArea(Collection $page_tree): string + { + $layout_default = config('connect.BASE_LAYOUT_DEFAULT'); + if (empty($this->page)) { + return $layout_default; + } + + $layout = null; + foreach ($page_tree as $tree_page) { + if (empty($tree_page) || empty($tree_page->layout)) { + continue; + } + + if ($tree_page->id != $this->page->id + && !is_null($tree_page->layout_inherit_flag) + && (int)$tree_page->layout_inherit_flag === 0) { + continue; + } - return (int)$frame->page_only === 0 - || ((int)$frame->page_only === 1 && (int)$page->id === $current_page_id) - || (int)$frame->page_only === 2; - }); + $layout = $tree_page->layout; + break; + } + + if (empty($layout)) { + $layout = Configs::getSharedConfigsValue('base_layout', $layout_default); + } + if (empty($layout)) { + $layout = $layout_default; } - // relation 未ロードの経路でも同じ条件で判定できるよう、フォールバックする。 - return Frame::where('page_id', $page->id) - ->where('area_id', $area_id) - ->where(function ($query) use ($current_page_id) { - // 共通エリアの取得条件は DefaultController と揃える。 - // page_only=0: 常に継承候補 - // page_only=1: 配置ページ本人のときだけ継承候補 - // page_only=2: 配置ページ本人では非表示でも、子ページでは継承候補 + return $layout; + } + + /** + * 共通エリアの継承候補フレームを取得する。 + */ + private function queryCommonAreaFrames(array $page_ids, int $area_id): Collection + { + return Frame::whereIn('frames.page_id', $page_ids) + ->where('frames.area_id', $area_id) + ->select('frames.*', 'frames.id as frame_id', 'pages.id as page_id') + ->join('pages', 'pages.id', '=', 'frames.page_id') + ->where(function ($query) { $query->where('page_only', 0) - ->orWhere(function ($query2) use ($current_page_id) { + ->orWhere(function ($query2) { $query2->where('page_only', 1) - ->where('page_id', $current_page_id); + ->where('page_id', $this->page->id); }) ->orWhere('page_only', 2); }) - ->exists(); + ->orderBy('pages._lft', 'desc') + ->orderBy('frames.display_sequence', 'asc') + ->get(); + } + + /** + * 多言語トップページを取得する。 + */ + private function getLanguageTopPage(): ?Page + { + if (empty($this->page) || empty($this->page->permanent_link) || !$this->isLanguageMultiOn()) { + return null; + } + + $languages = Configs::getLanguages(); + if (empty($languages)) { + return null; + } + + $page_language = $this->getPageLanguageFromPage($languages); + if (empty($page_language)) { + return null; + } + + return Page::where('permanent_link', '/' . $page_language)->first(); + } + + /** + * ページオブジェクトから言語を取得する。 + */ + private function getPageLanguageFromPage($languages) + { + $page_language = null; + $page_paths = explode('/', $this->page->permanent_link); + if ($page_paths && is_array($page_paths) && array_key_exists(1, $page_paths)) { + foreach ($languages as $language) { + if (trim($language->additional1, '/') == $page_paths[1]) { + $page_language = $page_paths[1]; + break; + } + } + } + + return $page_language; + } + + /** + * 多言語設定が有効か判定する。 + */ + private function isLanguageMultiOn(): bool + { + foreach (Configs::getSharedConfigs() as $config) { + if ($config->name !== 'language_multi_on') { + continue; + } + + return $config->value == '1'; + } + + return false; } /** diff --git a/tests/Feature/Core/ConnectPageFrameValidationFeatureTest.php b/tests/Feature/Core/ConnectPageFrameValidationFeatureTest.php index 75410eb13..8cb466cf7 100644 --- a/tests/Feature/Core/ConnectPageFrameValidationFeatureTest.php +++ b/tests/Feature/Core/ConnectPageFrameValidationFeatureTest.php @@ -195,6 +195,24 @@ public function testAncestorCommonAreaFrameWithPageOnlyCurrentIsForbiddenFromDes $this->assertSame(403, $response->getStatusCode()); } + /** + * テストの意図: + * 同一祖先ページ・同一エリアに継承可能フレームが混在しても、page_only=1 の本人専用フレームまでは子ページから操作できないことを守る。 + */ + public function testPageOnlyCurrentAncestorFrameRemainsForbiddenWhenSiblingFrameOnSameAreaIsInherited(): void + { + [, $parent, $child] = $this->createPageTree(); + $this->createContentsFrame($parent, AreaType::header); + $page_only_frame = $this->createContentsFrame($parent, AreaType::header, [ + 'display_sequence' => 2, + 'page_only' => 1, + ]); + + $response = $this->handleRequest($this->createRequest($child->id, $page_only_frame->id)); + + $this->assertSame(403, $response->getStatusCode()); + } + /** * テストの意図: * メインエリアは継承しないため、親ページ配置のフレームを子ページから操作できないことを守る。 @@ -223,6 +241,24 @@ public function testUnrelatedPageFrameIsForbidden(): void $this->assertSame(403, $response->getStatusCode()); } + /** + * テストの意図: + * 現在ページの解決済みレイアウトで共通エリアが非表示なら、祖先の継承フレームを手動URLで操作できないことを守る。 + */ + public function testAncestorCommonAreaFrameIsForbiddenWhenCurrentLayoutHidesArea(): void + { + [, $parent, $child] = $this->createPageTree(); + $frame = $this->createContentsFrame($parent, AreaType::header); + $child->update([ + 'layout' => '0|1|1|1', + 'layout_inherit_flag' => 1, + ]); + + $response = $this->handleRequest($this->createRequest($child->id, $frame->id)); + + $this->assertSame(403, $response->getStatusCode()); + } + /** * テストの意図: * 共通エリアは最も近い祖先の配置が優先され、より遠い祖先のフレームは子ページから操作できないことを守る。 From b63f8187167da3f83b34612bafc086499033e1bb Mon Sep 17 00:00:00 2001 From: gakigaki Date: Thu, 2 Apr 2026 15:50:29 +0900 Subject: [PATCH 3/3] docs(core): clarify common area frame validation comments --- app/Http/Middleware/ConnectPage.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/Http/Middleware/ConnectPage.php b/app/Http/Middleware/ConnectPage.php index 81a202e59..18014fdf9 100644 --- a/app/Http/Middleware/ConnectPage.php +++ b/app/Http/Middleware/ConnectPage.php @@ -468,9 +468,9 @@ private function isValidPageAndFrame($request, $page_tree) if (empty($page_tree) || empty($this->page) || empty($this->page->id)) { return false; } - + // この画面で実際に適用される共通エリアフレーム群を取得する。 $effective_frames = $this->getEffectiveCommonAreaFrames($page_tree, (int)$frame->area_id); - + // 要求された frame_id が、この画面で実際に適用されるフレーム群に含まれている場合だけ許可する。 return $effective_frames->contains(function ($effective_frame) use ($frame) { return (int)($effective_frame->frame_id ?? $effective_frame->id) === (int)$frame->id; }); @@ -525,10 +525,12 @@ private function normalizePageTreeForCommonArea($page_tree): Collection return collect(); } + // 元の page_tree は壊さず、共通エリア判定用の並びを別コレクションで組み直す。 $normalized_page_tree = collect($page_tree->all()); $top_page = Page::getTopPage(); $language_top_page = $this->getLanguageTopPage(); + // root 系ページは末尾へ正しい順序で付け直すため、いったん除外対象として集める。 $excluded_page_ids = collect([ $top_page->id ?? null, $language_top_page->id ?? null, @@ -536,12 +538,14 @@ private function normalizePageTreeForCommonArea($page_tree): Collection return (int)$page_id; })->all(); + // 通常の祖先チェーンだけを残し、root 系ページはこの後に付け直す。 $normalized_page_tree = $normalized_page_tree->filter(function ($tree_page) use ($excluded_page_ids) { return !empty($tree_page) && !empty($tree_page->id) && !in_array((int)$tree_page->id, $excluded_page_ids, true); })->values(); + // 表示側の解決順に合わせて、言語トップ -> 全体トップの順で末尾に戻す。 if (!empty($language_top_page) && !empty($language_top_page->id)) { $normalized_page_tree->push($language_top_page); }