如何用 header location 重定向并保留查询参数

SEO教程7小时前发布 jdkjadmin
0 00
如何用 header location 重定向并保留查询参数

如何用 header location 重定向并保留查询参数

在 PHP 中,header('Location: ...') 可以让服务器通知浏览器访问另一个地址。常见需求是跳转到新页面时保留原请求的查询参数,例如把 /old.php?category=books&page=2 转到 /new.php?category=books&page=2。

这里容易出错的地方不只是“把问号后面的内容拼上去”:查询字符串可能为空,目标地址可能已经有参数;参数也可能需要新增、覆盖或过滤;如果目标地址由用户输入,还可能形成开放重定向漏洞。本文以 PHP 为主,逐步说明处理方式、适用场景和边界。

先弄清楚:Location 不会自动保留查询参数

HTTP 响应中的 Location 头告诉客户端后续应访问哪个地址。它并不会自动把当前请求的查询参数搬到新地址。下面这段代码跳转到固定页面,但不会保留原请求参数:

<?php
header('Location: /new.php');
exit;

若请求是 /old.php?category=books&page=2,浏览器收到的目标仍是 /new.php,不会自动变成 /new.php?category=books&page=2。要保留参数,必须在生成 Location 值时明确附上它们。

最直接的做法:附加原始查询字符串

如果目标是固定的站内路径,而且需求是原样保留问号后的内容,可以读取 PHP 提供的 QUERY_STRING,并在它非空时附加到目标地址:

<?php
$target = '/new.php';
$query = $_SERVER['QUERY_STRING'] ?? '';

if ($query !== '') {
    $target .= '?' . $query;
}

header('Location: ' . $target, true, 302);
exit;

QUERY_STRING 是当前请求的查询字符串,不包含开头的 ?。例如,访问 /old.php?category=books&page=2 时,通常得到 category=books&page=2。如果没有查询字符串,上面的代码只跳转到 /new.php,不会留下多余的问号。

这一写法适用于目标路径固定、且确实要原样转发查询字符串的情况。它能保留参数的原始排列和重复键,例如 tag=a&tag=b,也不会把百分号编码过的字符先解析再重新编码。

但“原样保留”也意味着原始内容会被整体转发。不能因为它来自 QUERY_STRING,就认为它经过了业务校验或适合用于任何目标。若新页面只允许一部分参数,或需要修改、删除、覆盖参数,应先解析并按规则处理,而不是盲目转发整个字符串。

需要新增或覆盖参数时:解析后重新编码

当你需要保留部分原参数,同时增加或覆盖参数时,可以使用 PHP 的 parse_str() 解析,再用 http_build_query() 生成新的查询字符串:

<?php
$input = $_SERVER['QUERY_STRING'] ?? '';
$params = [];

if ($input !== '') {
    parse_str($input, $params);
}

// 设置或覆盖目标参数。
$params['source'] = 'legacy';

$query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
$target = '/new.php';

if ($query !== '') {
    $target .= '?' . $query;
}

header('Location: ' . $target, true, 302);
exit;

这里使用 parse_str($input, $params) 的第二个参数,将结果放入数组,避免把变量直接写入当前作用域。http_build_query() 会为参数名和值进行编码;指定 PHP_QUERY_RFC3986 时,空格会编码为 %20,而不是表单编码常见的加号形式。

解析再生成与原样转发并不等价。重新生成后,参数顺序、空格表示和部分编码形式可能变化;重复的普通键也可能被折叠。例如,tag=a&tag=b 经常会被解析为单个 tag 值,而带方括号的数组写法会按 PHP 的规则解析。参数名还可能受到 PHP 输入解析规则影响。因此,如果重复参数或原始编码形式具有业务意义,不要用解析再生成的方式代替原样转发。

覆盖顺序也很重要。上例先解析原参数,再设置 $params['source'],因此原请求中的 source 值会被服务端指定的值覆盖。若业务要求由用户值优先,代码顺序会不同;应明确决定,而不是依赖偶然的赋值顺序。

只允许白名单参数时

很多跳转并不需要保留全部参数。只传递页面确实用到的键,通常比转发整条查询字符串更容易审查,也能避免把内部标记或不需要的数据带到新页面。下面示例只保留 page 和 sort,并对页码做了基本校验:

<?php
$input = $_SERVER['QUERY_STRING'] ?? '';
$params = [];

if ($input !== '') {
    parse_str($input, $params);
}

$allowed = [];

if (isset($params['page']) && is_scalar($params['page'])) {
    $page = filter_var(
        $params['page'],
        FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 1, 'max_range' => 500]]
    );

    if ($page !== false) {
        $allowed['page'] = $page;
    }
}

$validSorts = ['newest', 'price'];
if (isset($params['sort'])
    && is_string($params['sort'])
    && in_array($params['sort'], $validSorts, true)
) {
    $allowed['sort'] = $params['sort'];
}

$query = http_build_query($allowed, '', '&', PHP_QUERY_RFC3986);
$target = '/catalog.php';

if ($query !== '') {
    $target .= '?' . $query;
}

header('Location: ' . $target, true, 302);
exit;

白名单并不代表所有参数都必须按整数处理,而是要根据应用的实际约定校验类型、范围和允许值。对于数组参数,也要明确接受的形状和最大项数;不要假设客户端传来的值一定是字符串或符合预期结构。

目标地址已经带有查询参数

如果目标地址本身已经有参数,直接拼接第二个问号会生成错误或不符合预期的地址。例如,目标是 /new.php?mode=compact,再无条件附加 ?page=2 会得到 /new.php?mode=compact?page=2。要追加参数,应根据目标是否已有查询部分选择分隔符:

<?php
$target = '/new.php?mode=compact';
$query = $_SERVER['QUERY_STRING'] ?? '';

if ($query !== '') {
    $separator = str_contains($target, '?') ? '&' : '?';
    $target .= $separator . $query;
}

header('Location: ' . $target, true, 302);
exit;

str_contains() 需要 PHP 8。如果项目仍运行较旧版本,可以用 strpos($target, '?') !== false 判断。更复杂的场景,尤其目标地址还可能包含片段标识符时,不建议继续堆叠字符串判断;应使用明确的 URL 处理逻辑,或把路径和参数分别管理。

浏览器地址中的 #section 是片段标识符,通常不会随 HTTP 请求发送给服务器。服务器端 PHP 一般无法从当前请求中取回原页面片段,因此不能靠读取 QUERY_STRING 保留它。若确实需要片段,应由客户端逻辑处理,或由应用通过其他明确的数据来源决定。

可运行的完整示例

下面的脚本可以保存为 redirect.php,用 PHP 内置开发服务器测试。它只跳转到写死的站内路径,保留原始查询字符串,并使用显式的 302 状态码:

<?php
declare(strict_types=1);

$target = '/destination.php';
$query = $_SERVER['QUERY_STRING'] ?? '';

if ($query !== '') {
    $target .= '?' . $query;
}

header('Location: ' . $target, true, 302);
exit;

在脚本所在目录运行:

php -S 127.0.0.1:8000

然后访问 http://127.0.0.1:8000/redirect.php?category=books&page=2。浏览器会收到重定向,并访问 /destination.php?category=books&page=2。如果目标文件不存在,浏览器仍会发起跳转,只是随后得到 404;测试时可以自行创建 destination.php,或把目标改为站内已有页面。

也可以不打开浏览器,使用 curl 查看响应头:

curl -i "http://127.0.0.1:8000/redirect.php?category=books&page=2"

响应中应能看到类似 HTTP/1.1 302 Found 和 Location: /destination.php?category=books&page=2。curl -i 默认只展示响应,不一定自动跟随重定向;加上 -L 才会继续请求目标地址。

安全边界:不要把用户输入直接当作跳转目标

保留查询参数和选择跳转目标是两件事。查询字符串通常不会改变固定站内路径的主机,但如果目标 URL 本身取自请求参数,就可能产生开放重定向:攻击者构造一个指向钓鱼站点的链接,借可信站点的域名让用户误以为目标安全。

不要直接这样写:

<?php
$next = $_GET['next'] ?? '/';
header('Location: ' . $next);
exit;

把输入校验成“看起来像 URL”并不总能解决问题。绝对地址、协议相对地址、编码后的字符和路径规范化等细节都可能造成绕过。更稳妥的方式是用服务器端白名单映射一个短标识到固定路径:

<?php
$destinations = [
    'home' => '/index.php',
    'account' => '/account.php',
];

$key = $_GET['next'] ?? 'home';

if (!is_string($key) || !isset($destinations[$key])) {
    $key = 'home';
}

header('Location: ' . $destinations[$key], true, 302);
exit;

如果业务必须允许跳转到站外地址,应实施明确的主机名白名单和规范化校验,并考虑协议限制;不要只用字符串包含判断,例如判断输入里是否出现了自家域名。对于只需返回站内页面的场景,固定相对路径或白名单路由通常更简单。

即便目标路径固定,也不要把未经验证的输入直接拼进 HTTP 响应头。当前 PHP 版本会对响应头中的换行等情况做相应处理,但应用仍应限制可控内容,并使用白名单或严格校验。不要把“运行环境可能拦截某种输入”当作安全设计。

参数编码与响应头输出

查询参数是由键值组成的编码数据。若自己从头构造参数,应对每个键和值分别进行 URL 编码,或优先使用 http_build_query()。不要把未经编码的用户输入直接拼到查询字符串中;其中的 &、=、# 等字符可能改变参数结构或被解释成其他部分。

反过来,若只是把可信范围内的原始 QUERY_STRING 原样转发,就不要先对整条字符串使用 urlencode()。那会把参数分隔符也编码,导致目标收到的内容不再是原来的多个参数。编码应针对参数键和值进行,而不是对已成形的整条查询字符串再次编码。

在 HTML 文档中,查询分隔符常显示为 &amp;,但 HTTP 地址实际使用的是字符 &。本文代码块采用 HTML 转义以保证页面源码有效;复制到 PHP 文件时,&amp;、&& 等 HTML 实体应还原为对应的 & 字符。这一点只适用于把代码嵌入 HTML 展示的场景,纯 PHP 源文件里应写正常的 &。

重定向状态码怎么选

重定向状态码决定客户端如何理解跳转,也会影响缓存与请求方法处理。常见选择如下:

  • 302 Found:常用于临时跳转。浏览器对原请求方法的处理存在历史兼容行为,不能把它当作保证保留 POST 方法的选择。
  • 303 See Other:常用于表单提交成功后转到结果页,指示客户端以 GET 请求获取另一个资源,适合“提交后跳转”的模式。
  • 307 Temporary Redirect:临时跳转,并要求客户端保留原请求方法和请求体。
  • 301 Moved Permanently:永久跳转,客户端和中间缓存可能记住结果;确认迁移长期有效后再使用。
  • 308 Permanent Redirect:永久跳转,并要求客户端保留原请求方法和请求体。

对普通的 GET 页面跳转,302 通常足够。若是 POST 后展示结果,常用 303;若服务需要在临时重定向中保留 POST 方法和请求体,则考虑 307。PHP 的 header('Location: ...') 通常会产生 302,但生产代码最好显式写出状态码,让行为清晰,也避免依赖默认值或应用其他响应逻辑。

重定向本身不是权限检查,也不会自动保护参数中的敏感信息。查询参数会出现在 URL 中,可能进入浏览器历史记录、服务器访问日志、监控系统或其他链路。密码、访问令牌和个人敏感数据不应因为要跨页面传递,就简单地放进查询参数。

参数数量和长度有没有固定上限

HTTP 的 Location 机制没有一个适用于所有浏览器、服务器、代理和框架的统一“查询参数个数上限”。实际可接受的 URL 长度可能受到客户端、Web 服务器、反向代理、中间件和应用自身配置影响;超过限制时,可能出现请求被拒绝、截断或其他错误。不能据此宣称所有环境都支持某个固定长度,也不应依赖超长 URL 传输大量数据。

PHP 还存在输入解析相关的配置,例如 max_input_vars,它会影响 PHP 解析输入变量时可处理的变量数量。它不是 Location 响应头的通用长度上限,也不能用来推断所有客户端和基础设施对 URL 的限制。通过读取原始 QUERY_STRING 与通过 $_GET 处理数据,经过的解析步骤也不同;具体行为应结合 PHP 版本和部署配置验证。

如果确实需要传递大量或复杂状态,优先考虑服务端会话、数据库中的短期状态记录,或提交后保存状态再跳转,而不是不断扩大查询字符串。涉及跨系统跳转时,也要考虑 URL 在日志和浏览器历史中的可见性。

避免“Headers already sent”

header() 必须在响应头发送之前调用。如果此前已经输出 HTML、普通文本、调试信息,或 PHP 文件在开头输出了不必要的内容,就可能看到 headers already sent 警告,重定向也可能失效。

可按以下方式排查:

  • 确保 header() 之前没有 echo、模板输出或调试内容。
  • 检查被包含文件是否意外输出字符,以及 PHP 文件开头标签前是否有空白或 BOM。
  • 查看警告指出的文件名和行号,定位首次输出的位置。
  • 重定向后立即执行 exit;,避免后续代码继续运行并产生输出。

输出缓冲有时会延迟响应发送,但不应把它当作掩盖输出顺序错误的通用办法。更稳妥的结构是先完成校验和业务逻辑,再设置响应头并退出。

常见错误速查

  • 认为 Location 会自动带参数:不会。要显式拼接或重新生成查询字符串。
  • 没有判断查询字符串是否为空:可能生成尾部多余的 ?。只在有内容时追加。
  • 目标已有参数却再次添加问号:应确认目标查询部分是否存在,并使用合适的分隔符。
  • 把整条查询字符串再做 URL 编码:可能让参数分隔符失效。重新生成参数时应编码键和值,优先使用 http_build_query()。
  • 解析后以为原始参数完全不变:重复键、编码形式和顺序都可能变化。对精确保真有要求时考虑原样转发。
  • 把用户输入直接写入 Location:可能造成开放重定向或不安全的响应头。使用固定目标或白名单映射。
  • 忘记退出脚本:重定向后再输出内容或执行敏感逻辑,通常都不是预期行为。调用 header() 后执行 exit;。
  • 把 301 用在尚未确定的迁移上:客户端可能缓存永久重定向。临时跳转先用合适的临时状态码。

如何选择实现方式

可以按需求快速决定:

  1. 目标固定,要求原样保留全部查询字符串:使用 $_SERVER['QUERY_STRING'],非空时附加到固定路径。
  2. 需要新增或覆盖参数:用 parse_str() 解析、按业务规则处理,再用 http_build_query() 生成新查询字符串。
  3. 只允许部分参数通过:解析后按白名单筛选,并验证类型与范围。
  4. 目标由用户决定:不要直接使用用户提供的完整 URL;优先采用服务器端白名单映射。
  5. URL 参数很多或包含敏感信息:改用服务端状态保存方案,而不是继续扩大查询字符串。

总体原则是:跳转目标要明确、参数处理要符合业务语义、编码只做一次且作用范围正确。对于简单的站内迁移,固定路径加原始查询字符串即可;一旦涉及参数筛选、覆盖或用户可控目标,就应把解析、校验和安全策略写清楚,而不是依赖字符串拼接碰巧得到正确结果。

© 版权声明

相关文章