把密码放进 URL(query 或 path)意味着它会被:
req.url
);结果是大面积、长期、不可逆的泄露足迹:即便你立刻修复代码,历史日志和外部系统依然留痕。更糟糕的是,很多安全扫描与审计默认采集 URL —— 进一步放大扩散范围。
观点:URL 不是私密信道。凡是能出现在 URL 的内容,都要假设它会被看见、被复制、被持久化。
误区 A:<form>
写成了 method="get"
?query=...
里。误区 B:前端 fetch
把参数拼到 URL
// ❌ 错误:把密码拼到 URL
fetch(`/api/auth/login?username=${u}&password=${p}`)
误区 C:后端从 req.query
读密码并打印日志
// ❌ 错误:从 query 读,并打印
const { username, password } = req.query;
console.log('DEBUG login', username, password);
误区 D:重定向/代理把 $args
原样透传
try_files
/rewrite
/proxy_pass
都可能把 querystring 原封不动带入访问日志与下游系统。?password=xxx
; 只要你在 Network 里能看到密码出现在 Request URL,就说明问题确凿。
前端(pages/login.tsx)
'use client';
import { useState } from 'react';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// ❌ 错误:把密码拼进 URL,用 GET 发送
const handleLogin = async () => {
const res = await fetch(`/api/auth/login?username=${username}&password=${password}`, {
method: 'GET',
});
const data = await res.json();
console.log(data);
};
return (
<div>
<h1>Login</h1>
<input value={username} onChange={e => setUsername(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button onClick={handleLogin}>Login</button>
</div>
);
}
后端(pages/api/auth/login.ts)
import type { NextApiRequest, NextApiResponse } from 'next';
// ❌ 错误:从 query 取,并打印明文密码
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const { username, password } = req.query;
console.log('DEBUG login:', username, password); // 泄露日志
if (username === 'admin' && password === '123456') {
return res.status(200).json({ ok: true });
} else {
return res.status(401).json({ ok: false });
}
}
前端(pages/login.tsx)
'use client';
import { useState } from 'react';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// ✅ 正确:POST + JSON body;不要把密码放 URL
const handleLogin = async () => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // 若使用 cookie session
body: JSON.stringify({ username, password }),
});
const data = await res.json();
console.log(data);
};
return (
<div>
<h1>Login</h1>
<input value={username} onChange={e => setUsername(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button onClick={handleLogin}>Login</button>
</div>
);
}
后端(pages/api/auth/login.ts)
import type { NextApiRequest, NextApiResponse } from 'next';
import bcrypt from 'bcryptjs';
const fakeUserDB = {
username: 'admin',
passwordHash: bcrypt.hashSync('123456', 10), // 仅示例:真实项目请从 DB 读取
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Method Not Allowed' });
const { username, password } = req.body; // ✅ 从 body 取
// ❌ 切勿记录明文密码
// console.log('DEBUG', username, password);
if (username === fakeUserDB.username) {
const ok = await bcrypt.compare(password, fakeUserDB.passwordHash);
if (ok) {
// TODO: 设置 HttpOnly 会话 cookie(建议使用专门会话库)
return res.status(200).json({ ok: true, message: 'Login success' });
}
}
return res.status(401).json({ ok: false, message: 'Invalid credentials' });
}
要点回顾: POST + Body 替代 GET + Query 后端只对 哈希 进行比较,不保存/打印明文 不在响应/重定向的 URL 中带任何敏感字段
# 仅记录 $uri(不含 querystring)
log_format no_args '$remote_addr - $remote_user [$time_local] '
'"$request_method $uri $server_protocol" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log no_args;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
SameSite=Lax/Strict
的 cookie; <form method="get">
改为 post
;把 fetch('?password=...')
改为 POST + JSON body。 req.body
取值;使用 bcrypt/argon2 存储与校验;不要 log 明文。 access_log
使用不含 $args
的格式,或对敏感参数脱敏;按上文添加安全响应头。 password
、authorization
、cookie
、token
等)。如果业务确实需要保留 query 字段,可对敏感参数进行脱敏重写。下例演示把
password
,passwd
,pwd
,token
,authorization
等参数重写为[REDACTED]
。实际生产可根据你们的参数命名扩展。
# 1) 拆出原始 querystring
map $request_uri $raw_query {
"~*\?(?<q>.*)$" $q;
default "";
}
# 2) 逐项脱敏(注意大小写与别名)
map $raw_query $sanitized_query_step1 {
"~*password=[^&]*" "$q";
default $raw_query;
}
# 3) 可继续链式替换(示意)。
# 由于原生 Nginx 对复杂正则替换支持有限,建议用 sub_filter 或 Lua/OpenResty 更灵活地处理。
# 这里给出基于 OpenResty 的更强方案:
OpenResty(推荐,灵活强大)
# http { ... }
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
# 自定义日志格式,使用变量 $sanitized_request
log_format main_sanitized '$remote_addr - $remote_user [$time_local] '
'"$sanitized_request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
# 在 server 或 location 里:
set $sanitized_request "";
rewrite_by_lua_block {
local req = ngx.var.request or "" -- e.g. "GET /path?password=123&token=abc HTTP/1.1"
local uri = ngx.var.request_uri or "" -- e.g. "/path?password=123&token=abc"
-- 只对 query 部分做替换
local path, query = uri:match("^([^?]+)%??(.*)$")
if query and #query > 0 then
-- 定义敏感参数列表(可扩展)
local patterns = {
"password", "passwd", "pwd", "token", "authorization", "auth", "secret", "apikey", "api_key"
}
for _, key in ipairs(patterns) do
-- 将 key=后面的值替换为 [REDACTED],大小写不敏感
-- 注意:此处简化处理,如需兼容多种编码/边界情况可增强正则
local reg = "(" .. key .. ")=([^&]*)"
query = query:gsub(reg, function(k, v) return k .. "=[REDACTED]" end)
-- 大小写不敏感版本:用 ngx.re.gsub
local new_query, n, err = ngx.re.gsub(query, "(" .. key .. ")=([^&]*)", "$1=[REDACTED]", "ijo")
if not err and new_query then query = new_query end
end
end
local method = ngx.var.request_method or "GET"
local httpver = ngx.var.server_protocol or "HTTP/1.1"
local sanitized = method .. " " .. (path or "/") .. (query and #query>0 and ("?"..query) or "") .. " " .. httpver
ngx.var.sanitized_request = sanitized
}
access_log /var/log/nginx/access.log main_sanitized;
说明:原生 Nginx 对“逐项替换”支持有限,OpenResty/Lua 可以按需自由扩展(推荐)。 若日志不需要 query,建议直接用不包含
$args
的log_format
(更简单/安全)。 无论是否脱敏,首先要从源头杜绝把密码放 URL。
Q1:我已经用 POST 了,为什么密码还出现在 URL?
A:多半是你在跳转或构造链接时用 window.location = '/?password=...'
之类写法;或后端 res.redirect(req.originalUrl)
把原始 query 带了出去。搜索“password”关键字,清理所有 URL 拼接点。
Q2:为什么 Referer 会泄露?
A:页面发起跳转或加载第三方资源时,浏览器会带上 Referer。若 URL 含密码,Referer 就会把它交给对方站点。用 Referrer-Policy,更重要的是不要把敏感信息放 URL。
Q3:代理和负载均衡的日志已经记录了怎么办?
A:紧急处置:限制访问、缩短会话有效期、强制重登、轮换密钥。同时清理或加密日志、调整留存策略,并开始彻查其他系统(APM/错误上报/审计)是否也采集了 URL。
安全不是一处修复,而是一条贯穿前端、后端、代理与运维的约束:
只要你把这条红线画清楚,配合本文的代码对比与落地清单,就能在 Next.js 的登录/注册场景中,从根本上堵住“密码出现在 URL” 的风险。
./images/cover-nextjs-auth-security.png
:封面图(建议:登录流程+盾牌图示)./images/devtools-network.png
:DevTools Network 面板标注“Request URL vs Request Payload”./images/nginx-logs.png
:Nginx 日志对比(包含 $args
vs 去除 $args
/脱敏)原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。