security: Token 改为 HttpOnly Cookie 下发,消除 XSS 窃取面

登录时网关签发 HttpOnly+Secure+SameSite=Lax 的 q4_token Cookie,前端不再经手 Token;网关代理时支持从 Cookie 验签并向下游注入 Authorization(dnfs 零改动),新增 /logout 清除 Cookie,static 代理路由兼容 Cookie 转 Bearer。
This commit is contained in:
qing committed 2026-07-28 09:07:12 +08:00
1 parent 65cfaf97a1
commit e89578fa82
4 files changed
+102 -28

No files matched your search

+79 -3
View File
@@ -26,7 +26,7 @@ using namespace qing;
namespace json = boost::json;
namespace po = boost::program_options;
constexpr const char *_VER = "0.4.0";
constexpr const char *_VER = "0.4.1";
//////////////////////////////////
#include <sstream>
@@ -291,6 +291,13 @@ http::response<http::string_body> handleCaptcha(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params);
http::response<http::string_body> handleLogout(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params);
/* 从 Cookie 头中解析指定名称的值(找不到返回空串) */
std::string get_cookie_value(const std::string& cookie_header, const std::string& name);
/* headers can't processed */
bool is_special_header(const std::string &header);
@@ -532,6 +539,11 @@ void registerRoutes(HttpServer &server) {
[](const auto& req, const auto& params) { return handleRegister(req, params); });
});
server.post("/logout", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params,
[](const auto& req, const auto& params) { return handleLogout(req, params); });
});
server.get("*", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params,
[](const auto& req, const auto& params) { return handleProxy(req, params, "get"); });
@@ -776,12 +788,38 @@ http::response<http::string_body> handleLogin(
}
};
res.set("Connection", "close");
/* 签发 HttpOnly Cookie(防 XSS 窃取 Token;JS 不再经手 Token) */
{
std::string ck = "q4_token=" + token +
"; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" +
std::to_string(_TOKEN_KEEP_TIME);
res.set("Set-Cookie", ck);
}
res.result(http::status::ok);
res.body() = json::serialize(jv_rr);
res.prepare_payload();
return res;
}
/* 退出登录 /logout:清除 q4_token Cookie */
http::response<http::string_body> handleLogout(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params)
{
http::response<http::string_body> res;
res.version(req.version());
res.set("Set-Cookie",
"q4_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0");
res.set("Connection", "close");
res.result(http::status::ok);
res.body() = R"({"success": true, "message": "logged out"})";
res.prepare_payload();
return res;
}
/* 处理注册 /register */
http::response<http::string_body> handleRegister(
const http::request<http::string_body>& req,
@@ -878,6 +916,7 @@ http::response<http::string_body> handleProxy(
std::string t_url = "";
std::string url = "";
std::string auth = "";
std::string cookie_token = ""; /* 从 Cookie 提取的 Token(用于向下游注入 Authorization) */
json::value jv_t;
{
auto flag = false;
@@ -912,10 +951,21 @@ http::response<http::string_body> handleProxy(
url += "/";
url += target_path;
std::string raw = "";
it = req.base().find("Authorization");
if (it == req.base().end() && !flag)
if (it != req.base().end()) {
raw = std::string(it->value());
} else {
/* 回退:从 HttpOnly Cookie 读取 q4_token(浏览器同源请求自动携带) */
auto cit = req.base().find("Cookie");
if (cit != req.base().end()) {
cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
if (!cookie_token.empty())
raw = "Bearer " + cookie_token;
}
}
if (raw.empty() && !flag)
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
std::string raw = req["Authorization"];
if (raw.substr(0, 7) == "Bearer ") {
raw = raw.substr(7, raw.size() - 7);
size_t idx = raw.find("_");
@@ -996,6 +1046,11 @@ http::response<http::string_body> handleProxy(
client.set_header("X-Forwarded-For: unknown");
}
/* Token 来自 Cookie 时,向下游补齐 Authorization 头(dnfs 等下游仍按 Bearer 解析,无需改动) */
if (!cookie_token.empty()) {
client.set_header("Authorization: Bearer " + cookie_token);
}
/* 转发请求 */
if (method == "get")
client.get_stream_sync(url, callback);
@@ -1031,6 +1086,27 @@ bool is_special_header(const std::string &header) {
|| header == "Forwarded";
}
std::string get_cookie_value(const std::string& cookie_header, const std::string& name) {
// Cookie 头格式:"k1=v1; k2=v2; q4_token=xxx"
size_t pos = 0;
while (pos <= cookie_header.size()) {
size_t end = cookie_header.find(';', pos);
if (end == std::string::npos) end = cookie_header.size();
std::string pair = cookie_header.substr(pos, end - pos);
size_t eq = pair.find('=');
if (eq != std::string::npos) {
std::string k = pair.substr(0, eq);
size_t b = k.find_first_not_of(" \t");
size_t e = k.find_last_not_of(" \t");
if (b != std::string::npos) k = k.substr(b, e - b + 1);
if (k == name) return pair.substr(eq + 1);
}
if (end == cookie_header.size()) break;
pos = end + 1;
}
return "";
}
bool is_safe_target(const std::string& target) {
// 请求路径安全性校验(P1-1)
// 防御目标:路径遍历、协议注入、空字节、非绝对路径形式
+4
View File
@@ -411,6 +411,7 @@ def proxy_get_notes():
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/get_notes", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
@@ -426,6 +427,7 @@ def proxy_add_note():
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/add_note", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
@@ -440,6 +442,7 @@ def proxy_delete_note():
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/delete_note", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
@@ -454,6 +457,7 @@ def proxy_update_note():
data = request.get_json(force=True)
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/update_note", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
+18 -23
View File
@@ -1,11 +1,10 @@
// Session restore from localStorage
if(!sessionStorage.getItem('q4_token')){var t=localStorage.getItem('q4_token');if(t)sessionStorage.setItem('q4_token',t);}
// 认证改为 HttpOnly Cookie(gateway 下发 q4_token),浏览器同源请求自动携带,JS 不再经手 Token
/* by qing.
*
* 用于获取一个节点下的所有子节点信息,0为根节点。
*/
async function getNode(token, id) {
async function getNode(id) {
try {
obj = { "pid": id }
jsonBody = JSON.stringify(obj)
@@ -14,7 +13,6 @@ async function getNode(token, id) {
method: 'POST',
headers: {
'Content-Type': 'application/json', // 告诉服务器内容为json
'Authorization': `Bearer ${token}`,
'Target-Url': 'http://127.0.0.1:8006'
},
body: jsonBody
@@ -54,7 +52,7 @@ async function getNode(token, id) {
*
* 用于向dnfs添加节点
*/
async function addNode(token, pid, title, content) {
async function addNode(pid, title, content) {
try {
obj = {
"pid": pid,
@@ -67,7 +65,6 @@ async function addNode(token, pid, title, content) {
method: 'POST',
headers: {
'Content-Type': 'application/json', // 告诉服务器内容为json
'Authorization': `Bearer ${token}`,
'Target-Url': 'http://127.0.0.1:8006'
},
body: jsonBody
@@ -89,8 +86,8 @@ async function addNode(token, pid, title, content) {
input_note_title.value = "";
input_note_content.value = "";
getNode(token, stack[stack.length - 1]).then(load_notes);
getNode(token, stack[stack.length - 1]).then(loadMap)
getNode(stack[stack.length - 1]).then(load_notes);
getNode(stack[stack.length - 1]).then(loadMap)
//if (result.success) {
alert(`添加节点成功`, result);
@@ -108,7 +105,7 @@ async function addNode(token, pid, title, content) {
}
}
async function deleteNode(token, id) {
async function deleteNode(id) {
if (confirm("确认删除此节点?") == false) {
return;
}
@@ -121,7 +118,6 @@ async function deleteNode(token, id) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Target-Url': 'http://127.0.0.1:8006'
},
body: jsonBody
@@ -142,8 +138,8 @@ async function deleteNode(token, id) {
}
stack.pop();
getNode(token, stack[stack.length - 1]).then(load_notes);
getNode(token, stack[stack.length - 1]).then(loadMap)
getNode(stack[stack.length - 1]).then(load_notes);
getNode(stack[stack.length - 1]).then(loadMap)
console.log(`删除节点成功`);
return result
@@ -182,7 +178,7 @@ input_note_submit.addEventListener('click', function(event) {
var pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
//alert(stack.length)
//alert(pid)
addNode(token, pid, title, content).then(result => {
addNode(pid, title, content).then(result => {
//alert(result.message);
})
})
@@ -199,7 +195,7 @@ delete_note.addEventListener('click', (event) => {
console.log("节点未删除");
}
else {
deleteNode(token, id);
deleteNode(id);
}
})
@@ -232,8 +228,8 @@ function load_notes(main_node) {
card.addEventListener('click', function(event) {
var id = Number(item.id)
stack.push(id)
getNode(token, id).then(load_notes)
getNode(token, id).then(loadMap)
getNode(id).then(load_notes)
getNode(id).then(loadMap)
});
let ttl = document.createElement('center');
let ttl1 =document.createElement('h3');
@@ -274,10 +270,10 @@ function loadMap(children_json) {
fold_div.addEventListener("click", () => {
if (is_expand == true) {
is_expand = false;
getNode(token, stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
getNode(stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
} else {
is_expand = true;
getNode(token, stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
getNode(stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
}
});
if (need_return == true) {
@@ -290,8 +286,8 @@ function loadMap(children_json) {
back.addEventListener("click", () => {
stack.pop();
var id = (stack.length >= 0) ? stack[stack.length - 1] : 0;
getNode(token, id).then(load_notes);
getNode(token, id).then(loadMap);
getNode(id).then(load_notes);
getNode(id).then(loadMap);
});
minimap.appendChild(back);
}
@@ -309,6 +305,5 @@ function loadMap(children_json) {
}
}
var token = sessionStorage.getItem('q4_token')
getNode(token, 0).then(load_notes);
getNode(token, 0).then(loadMap)
getNode(0).then(load_notes);
getNode(0).then(loadMap)
+1 -2
View File
@@ -76,8 +76,7 @@ form.addEventListener('submit', async function(e) {
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
if (!res.ok) throw new Error(result.message || '登录失败');
if (result.success) {
localStorage.setItem('q4_token', result.data.token);
sessionStorage.setItem('q4_token', result.data.token);
// Token 已由网关通过 HttpOnly Cookie 下发,JS 不再经手
alert('登录成功');
location.href = '/dnfs.html';
} else {