Files
saasshop/app/Support/BackUrl.php

284 lines
9.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Support;
class BackUrl
{
/**
* back 参数安全护栏(用于 Blade 中 `{!! !!}` 输出的 href 场景):
* - 仅允许站内相对路径(/ 开头)
* - 拒绝引号/尖括号(防属性注入/XSS
* - 拒绝 nested back=(防 URL 膨胀/绕过)
*/
public static function sanitizeForLinks(string $incomingBack): string
{
$incomingBack = (string) $incomingBack;
if ($incomingBack === '') {
return '';
}
// 长度安全阀:避免超长 back 造成 header/url 过大、日志污染或潜在 DoS
// 与表单侧 max:2000 的校验口径对齐。
if (strlen($incomingBack) > 2000) {
return '';
}
if (!str_starts_with($incomingBack, '/')) {
return '';
}
// 拒绝协议相对 URL例如 //evil.com避免 open redirect
if (str_starts_with($incomingBack, '//')) {
return '';
}
if (preg_match('/["\'<>]/', $incomingBack)) {
return '';
}
// 拒绝控制字符/CRLF 注入(包括明文与常见 URL 编码形式)
if (preg_match('/[\r\n\t]/', $incomingBack)) {
return '';
}
if (preg_match('/%0d|%0a|%09/i', $incomingBack)) {
return '';
}
// 拒绝 back 自身再包含 back=(避免无限嵌套导致 URL 膨胀,且容易绕过页面侧护栏)
// 同时拒绝“二次编码”的 back%3D例如 %2526back%253D 经过一次 urldecode 后变成 %26back%3D
// 在浏览器点击后会再次被解码为 &back=,形成绕过)。
if (preg_match('/(?:^|[?&])back=/', $incomingBack)) {
return '';
}
if (preg_match('/(?:^|[?&]|%26|%3f)back%3d/i', $incomingBack)) {
return '';
}
if (preg_match('/back%253d/i', $incomingBack)) {
return '';
}
return $incomingBack;
}
/**
* 安全版“保留当前 query 并覆盖字段”的站内相对链接构造器。
*
* 典型用途:列表页里的各种「统计卡/治理入口/快捷链接」需要:
* - 保留当前筛选条件
* - 覆盖指定字段
* - 并且 back 只能保留通过 sanitizeForLinks 的安全值(否则移除)
*/
public static function currentPathWithQuery(array $overrides = [], string $safeBackForLinks = ''): string
{
$q = request()->query();
if ($safeBackForLinks !== '') {
$q['back'] = $safeBackForLinks;
} else {
unset($q['back']);
}
foreach ($overrides as $k => $v) {
if ($v === null) {
unset($q[$k]);
} else {
$q[$k] = $v;
}
}
$url = '/' . ltrim(request()->path(), '/');
if (count($q) > 0) {
$url .= '?' . \Illuminate\Support\Arr::query($q);
}
return $url;
}
/**
* 给指定站内相对路径附加安全 back若 back 为空则原样返回)。
*
* 用途:列表页“全部/返回来源”等需要清空筛选但保留 back 的场景。
*/
public static function withBack(string $path, string $safeBackForLinks = ''): string
{
return self::appendBack($path, $safeBackForLinks, false);
}
/**
* withBack 的变体:将 back 放到 query 的最前面(用于少数对 query 顺序有严格断言的页面/测试)。
*/
public static function withBackFirst(string $path, string $safeBackForLinks = ''): string
{
return self::appendBack($path, $safeBackForLinks, true);
}
private static function appendBack(string $path, string $safeBackForLinks = '', bool $preferFirst = false): string
{
$path = (string) $path;
if ($safeBackForLinks === '') {
return $path;
}
// 防御性:即使调用方声称传入的是 safeBack这里也再按统一口径过滤一遍。
// 若不合法,则不拼接 back避免注入/嵌套 back= 膨胀/外链等风险)。
$safeBackForLinks = self::sanitizeForLinks((string) $safeBackForLinks);
if ($safeBackForLinks === '') {
return $path;
}
// 仅支持站内相对路径;若调用方传入了异常值,这里不做拼接,直接返回原 path。
if ($path === '' || !str_starts_with($path, '/')) {
return $path;
}
// 兼容:若调用方传入的 path 自带 fragment#xxx这里拆出并在最后追加。
// fragment 仍做白名单校验(与 withBackAndFragment 同口径),避免意外注入/属性污染。
$fragmentSuffix = '';
if (str_contains($path, '#')) {
[$path, $fragment] = explode('#', $path, 2);
$fragment = ltrim((string) $fragment, '#');
if ($fragment !== '' && preg_match('/^[A-Za-z0-9_-]+$/', $fragment)) {
$fragmentSuffix = '#' . $fragment;
}
}
$backQuery = \Illuminate\Support\Arr::query(['back' => $safeBackForLinks]);
if (!str_contains($path, '?')) {
return $path . '?' . $backQuery . $fragmentSuffix;
}
[$base, $qs] = explode('?', $path, 2);
$qs = trim((string) $qs);
$qs = trim($qs, "& ");
// 处理类似 "/xx?" 或 "/xx?&" 的情况:视为无 query
if ($qs === '') {
return $base . '?' . $backQuery . $fragmentSuffix;
}
// 若 path 自身已包含 back=(调用方误用),则不再追加,避免重复 back 造成 URL 膨胀/绕过。
if (preg_match('/(?:^|&)back=/', $qs)) {
return $base . '?' . $qs . $fragmentSuffix;
}
if ($preferFirst) {
return $base . '?' . $backQuery . '&' . $qs . $fragmentSuffix;
}
return $base . '?' . $qs . '&' . $backQuery . $fragmentSuffix;
}
/**
* 给指定站内相对路径附加安全 back并可选追加锚点fragment
*
* 说明fragment 仅允许 [A-Za-z0-9_-],不符合则直接丢弃 fragment。
* 典型用途:列表页行级「去补回执 / 去补退款」等链接,需要跳转到详情页某个区块。
*/
public static function withBackAndFragment(string $path, string $safeBackForLinks = '', string $fragment = ''): string
{
// 若调用方传入了 path 自带 fragment同时又传入 fragment 参数,避免出现 "#old#new"。
if ((string) $fragment !== '' && str_contains((string) $path, '#')) {
[$path] = explode('#', (string) $path, 2);
}
$url = self::withBack($path, $safeBackForLinks);
$fragment = ltrim((string) $fragment, '#');
if ($fragment === '') {
return $url;
}
if (!preg_match('/^[A-Za-z0-9_-]+$/', $fragment)) {
return $url;
}
return $url . '#' . $fragment;
}
/**
* withBackFirst + fragment 的组合:用于少数对 query 顺序有严格断言的页面/测试。
*/
public static function withBackFirstAndFragment(string $path, string $safeBackForLinks = '', string $fragment = ''): string
{
// 若调用方传入了 path 自带 fragment同时又传入 fragment 参数,避免出现 "#old#new"。
if ((string) $fragment !== '' && str_contains((string) $path, '#')) {
[$path] = explode('#', (string) $path, 2);
}
$url = self::withBackFirst($path, $safeBackForLinks);
$fragment = ltrim((string) $fragment, '#');
if ($fragment === '') {
return $url;
}
if (!preg_match('/^[A-Za-z0-9_-]+$/', $fragment)) {
return $url;
}
return $url . '#' . $fragment;
}
/**
* 当前路径下的“快捷筛选”链接构造器:
* - 仅保留指定上下文键(例如 merchant_id/plan_id/keyword/lead_id 等)
* - 覆盖 overridesnull 表示移除)
* - 强制清空 page避免落到空页
* - back 仅保留安全值(由调用方传入 sanitizeForLinks 产物)
*/
public static function currentPathQuickFilter(array $contextKeys, array $overrides = [], string $safeBackForLinks = ''): string
{
$path = '/' . ltrim(request()->path(), '/');
$contextMap = [];
foreach ($contextKeys as $k) {
$contextMap[(string) $k] = 1;
}
$q = array_intersect_key(request()->query(), $contextMap);
if ($safeBackForLinks !== '') {
$q['back'] = $safeBackForLinks;
} else {
unset($q['back']);
}
// 快捷筛选不应继承分页
unset($q['page']);
foreach ($overrides as $k => $v) {
if ($v === null) {
unset($q[$k]);
continue;
}
$q[$k] = $v;
}
if (count($q) === 0) {
return $path;
}
return $path . '?' . \Illuminate\Support\Arr::query($q);
}
/**
* 生成当前页面去掉 back 后的 selfWithoutBack站内相对路径
* 用于:生成返回来源 back 或防止 back 嵌套膨胀。
*/
public static function selfWithoutBack(): string
{
$currentQuery = request()->query();
unset($currentQuery['back']);
$url = '/' . ltrim(request()->path(), '/');
if (count($currentQuery) > 0) {
$url .= '?' . \Illuminate\Support\Arr::query($currentQuery);
}
return $url;
}
}