Developer tools
Step 94. A Taste of Flask — Building Your Own Web Server
Level 1 — Programming and the Inside of a Computer | Difficulty ★★★☆☆ | Estimated time: 3 hours
Prerequisites: HTTP knowledge from Step 73 and Python basics from Steps 41–46.
- What you need: Python and
pip install flask. This chapter’s measurements were performed on Flask 3.1.3 at127.0.0.1(localhost). - Caution: from today, you build the side that receives requests (the server), not the side that sends them (the client). All exercises run on a development server inside your own computer and are never exposed externally. In the second half there’s an experiment where you build a deliberately vulnerable page — only inside your server and your browser.
In Step 73 we learned how to send HTTP requests. But one thing was missing — what happens on the side that receives the request? Today we open that black box. The tool is Flask — the smallest web framework, able to launch a web server with a few lines of Python. A vulnerability isn’t an abstract concept; it’s a hole in code someone wrote. So you’ll write the vulnerable code yourself — in today’s final scene, you’ll feel XSS (cross-site scripting) at your fingertips for the first time.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Launch a server with two or more routes using Flask
- Receive and handle query strings (GET) and form data (POST) respectively
- Build a JSON API by returning a dictionary
- Trace a request by comparing the browser’s Network tab with the server log
- Explain code where XSS occurs, its principle (code-data mixing), and escaping as a defense
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 + Flask 3.x, a localhost (127.0.0.1) development server |
| Today’s code | @app.route (routing), request.args (GET parameters), request.form (POST forms), dictionary return (JSON), markupsafe.escape (escaping) |
| Concepts needed | Routing, GET/POST, query strings, status codes (200/404/405), XSS and escaping |
| Today’s artifacts | A mini guestbook web app + XSS success/block contrast captures |
2-1. Frameworks and Routing — Connecting URLs to Functions
The essence of what a web server does is simple: “when this request comes to this address, run this function and return its result.” Connecting an address (URL) to a function is called routing. In Flask, one decorator (@) line is routing.
@app.route("/hello")
def hello():
return "Hi!"
“When a GET request comes to /hello, run hello() and send its return value as the response body.” These two lines are both the whole and the beginning of a web server.
2-2. GET and POST — Seen Again from the Server’s Side
Let’s organize the two methods from Step 73, this time from the receiving side’s viewpoint.
- request.args reads the URL query for any method, including POST.
- request.form reads supported form-encoded request content. A POST can carry both a query and a form body.
- Keep real secrets out of URLs; use HTTPS for transport protection and control endpoint logging.
2-3. The Server-Side Viewpoint — “All Input Is Suspicious”
Once you start writing server code, your worldview changes. Until now you were the browser’s user, but from today you’re the side asking “is whatever sent that request really a nice user?”
Every input the server receives — URL parameters, form text boxes, cookies — can be manipulated at will by the user, because anyone can change values by opening developer tools. “Every input arriving at the server may be contaminated” — this one sentence is web security’s first principle, and today’s XSS experiment is its proof.
3. Follow Along
3-1. Installation and the Minimal Server
Input
pip install flask
mkdir flask-lab && cd flask-lab
app.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello, Web!"
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
Output (measured 2026-09-09, python app.py):
* Serving Flask app 'app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
The development server listens on loopback port 5000. Keep debug mode off for this exercise and restart manually after edits. Flask’s interactive debugger can execute Python code; the PIN is not a production security boundary. A development server is not a production deployment.
Why: a server ran in five lines. The framework handles all the tedious parts (sockets, HTTP parsing) for you, so you only have to think about “what to show.”
3-2. A Request’s Journey — Watching the Browser and Console Simultaneously
Input: in the browser, open developer tools (F12) → the Network tab, and refresh http://127.0.0.1:5000.
Output (measured 2026-09-09, server console):
127.0.0.1 - - [09/Sep/2026 14:29:13] "GET / HTTP/1.1" 200 -
How to read it: two faces of the same event. The browser’s Network tab shows one request line (status 200); the server console shows one access-log line — who (127.0.0.1), when, what (GET /), and how it turned out (200) are stamped.
Why: engraving the correspondence “one request = one log line” in your body is the foundation for the instinct of tracing attacks through server logs later.
3-3. Receiving URL Parameters — Query Strings
Input: add the following imports and route to app.py BEFORE its final if __name__ == “__main__” block. Keep that app.run block last for every added route. Stop and restart the local server after saving each change.
from flask import request
from markupsafe import escape
@app.route("/hello")
def hello():
name = request.args.get("name", "guest")
return f"Hello, {escape(name)}!"
Output (measured 2026-09-09): http://127.0.0.1:5000/hello?name=hacker → Hello, hacker!. Opening just /hello without parameters → Hello, guest!.
How to read it: the server pulled out the value of ?name=hacker and loaded it into the response. The second argument of request.args.get("name", "guest") is the default when the parameter is absent.
Why: your first experience of a server receiving and processing user input. And as you may have noticed — that name can be changed at will by the user. This fact explodes in 3-7.
3-4. Forms and POST — A Mock Login
Input: add a login page and a handling route.
# Add to the app.py above. Fictional inputs only; this is not authentication.
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
uid = request.form.get("uid", "")
pw = request.form.get("pw", "")
return f"Practice ID {escape(uid)} (dummy password length: {len(pw)})"
return """<form method="post">
<input name="uid" placeholder="fictional ID">
<input name="pw" type="password" placeholder="dummy only">
<button>Submit practice form</button></form>"""
Output (measured 2026-09-09): open /login (GET) and the form shows; enter ID tester and an 8-character password and submit (POST), and this comes back.
login attempt with ID tester (password length: 8)
How to read it: the same /login address, but different behavior by method — GET shows the form, POST handles it. It must say <form method="post"> for data to go in the body (POST). Note that we showed only the length of the password instead of echoing it back on screen — showing a received secret back is a bad habit from the start.
This route only echoes fictional form data; it does not validate a password or establish a session. Real authentication requires a separate reviewed design. Here, compare the GET that displays the form with the POST that submits it.
3-5. Make a Prediction — What Gets Stamped on the Server Console?
Prediction: open the /login form (first visit) and press the submit button — what logs get stamped on the server console?
- (a) One line of
"GET /login ..." - (b) One line of
"POST /login ..." - (c) One line each of GET and POST
Check for yourself — output (measured 2026-09-09):
127.0.0.1 - - [09/Sep/2026 14:29:13] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [09/Sep/2026 14:29:13] "POST /login HTTP/1.1" 200 -
The answer is (c). “Viewing the form” and “sending the form” are two separate requests.
Logs are evidence, but can be incomplete, misleading, or tampered with. Compare this server log with the browser request, timestamps, and known local activity rather than assuming every line is trustworthy or that every request was logged.
3-6. JSON Responses — A Server That Returns Data, Not Screens
This API example returns JSON, one common structured representation. APIs can use other formats. The values below are fixed demonstration data, not a live measurement of open ports.
Input
@app.route("/api/status")
def api_status():
return {"service": "netwatch-lab", "open_ports": [8000], "status": "ok"}
Output (measured 2026-09-09, visiting http://127.0.0.1:5000/api/status):
{"open_ports":[8000],"service":"netwatch-lab","status":"ok"}
How to read it: return a Python dictionary and Flask automatically translates it to JSON. It looks like a blob of characters in the browser, but this response is in a form another program can read mechanically.
Step 95 produces structured observation data through a command-line program. It does not include this Flask API. A future web interface would require its own access controls and review.
3-7. A Taste of Vulnerability — The Birth of XSS
Now we write bad code on purpose. A route that loads input straight into HTML.
Input
@app.route("/echo")
def echo():
name = request.args.get("name", "")
return f"<h1>your input: {name}</h1>" # inserting input into HTML as-is — vulnerable!
First a normal input — /echo?name=gildong shows your input: gildong as a bold heading. Now the attack input. In the address bar:
http://127.0.0.1:5000/echo?name=<script>alert('XSS')</script>
Output (measured 2026-09-09, server response body):
<h1>your input: <script>alert('XSS')</script></h1>
The server loaded the input as-is with no processing and returned it, and the browser receiving this response interpreted <script> not as “content” but as a “command” and executed the alert box. The input was executed as a script, not data.
How to read it: this is XSS (cross-site scripting) — exactly the same root as Step 93’s SQL Injection (mixing code and data). Just as a quote became grammar in SQL, here <script> became HTML grammar.
Why: this single alert is the target of hundreds of web problems in hacking competitions. Now that you’ve felt “input gets executed” on your own server, you’re ready to face this vulnerability head-on.
⚠️ Security connection: this experiment was performed only on my own server on my own computer. All attack practice stays in your own lab and legal platforms. Unauthorized attacks on real services are a crime. Putting this input into someone else’s site’s search box is not an experiment — it’s an attack attempt.
3-8. A Taste of Defense — One Line of Escaping
Having seen vulnerable code, let’s see the defense too. There’s a tool that safely transforms input.
Input
from markupsafe import escape
@app.route("/echo_safe")
def echo_safe():
name = request.args.get("name", "")
return f"<h1>your input: {escape(name)}</h1>"
Output (measured 2026-09-09, same attack input):
<h1>your input: <script>alert('XSS')</script></h1>
In this HTML text context, escape encodes markup characters so the example script is displayed as text. This is not a universal defense for JavaScript, CSS, URL, or later decoding contexts. Match output encoding and validation to the actual destination.
Why: knowing the attack and knowing the defense come as one set. Today you saw both XSS’s occurrence and its blocking within 30 lines — this contrast is why it sticks in memory.
Version note (measured 2026-09-09, Flask 3.1.3): the from flask import escape found in older materials now errors — ImportError: cannot import name 'escape' from 'flask'. It was removed in Flask 2.3, so the correct form is the one above, importing directly from markupsafe, the safety-processing tool Flask uses internally.
4. Missions & Exercises
Mission — Completing a Mini Guestbook Web App
/— make a page that shows the list of messages left so far./write— make a form that takes a name and a one-line message (GET display + POST handling).- Store POSTed messages in a Python list and list them on
/. But you must applyescapeon output. - Keep the deliberately vulnerable version (
/echo), and secure side-by-side captures of XSS succeeding and of it failing after escape is applied. - Capture a screen comparing the server console’s request log with the browser’s Network tab.
- Write
Flask-first-server.mdin your wiki — the routing concept / the GET·POST difference / 3 lines on why XSS happens.
Exercises
Exercise 1. Explain what the single decorator line @app.route("/hello") does, using the phrase “connecting an address to a function.”
Exercise 2. Distinguish request.args from request.form. Explain why secrets should not be placed in URLs and why a POST body still needs HTTPS and careful logging.
Exercise 3. If you submit a form with methods=["GET", "POST"] missing from the route, what status code comes back? Explain why.
Exercise 4. Explain the principle by which escape blocks XSS from the perspective of “mixing code and data.”
Answers & completion criteria · expand/collapse
5. Model Answers & Completion Criteria
Mission Model Answer
Skeleton of the guestbook:
"""Local HTML-output exercise; no authentication, CSRF protection, or durability."""
from collections import deque
from threading import Lock
from flask import Flask, request
from markupsafe import escape
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 8192
guestbook = deque(maxlen=100)
lock = Lock()
@app.route("/")
def home():
with lock:
entries = list(guestbook)
items = "".join(
f"<li><b>{escape(name)}</b>: {escape(msg)}</li>"
for name, msg in entries)
return f"<h1>Guestbook</h1><ul>{items}</ul><a href='/write'>Write</a>"
@app.route("/write", methods=["GET", "POST"])
def write():
if request.method == "POST":
name = request.form.get("name", "anonymous").strip() or "anonymous"
msg = request.form.get("msg", "").strip()
if not msg or len(name) > 64 or len(msg) > 500:
return "Use a name up to 64 characters and a 1-500 character message", 400
with lock:
guestbook.append((name, msg))
return "Saved. <a href='/'>Back to list</a>"
return '''<form method="post">
<input name="name" maxlength="64" placeholder="name">
<input name="msg" maxlength="500" placeholder="one-line message">
<button>Leave it</button></form>'''
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=False)
How to verify: ① when you leave a post at /write, does it appear on /? ② even with <script>alert(1)</script> in a message, does it show only as text on / without executing (escape check)? ③ does the same input execute in the vulnerable version /echo — have you secured the two contrast captures? ④ are GET/POST logs stamped on the server console in time order? All “yes” means complete.
Exercise Answers
Answer 1. It’s a connection (routing) declaration saying “when a GET request comes to the address /hello, run the function right below and return its return value as the response body.” Connecting addresses to functions is a web server’s essence, and Flask expresses it in one decorator line.
Answer 2: request.args reads query parameters regardless of method; request.form reads supported form data. URLs can appear in history and logs, so keep secrets out of them. POST does not supply encryption and bodies may still be logged; transport and endpoint protections are separate.
Answer 3: POST to this route returns 405 because POST was not allowed. Flask routes default to GET and ordinarily add HEAD and automatic OPTIONS handling. Configure methods to match the intended form operation.
Answer 4: escape encodes special characters for the HTML text context used here. It is not a universal guarantee for JavaScript, CSS, URLs, or later decoding/reinterpretation. Choose encoding and validation for the actual output context. SQL binding is a separate mechanism that binds values rather than identifiers.
Completion Criteria Checklist
- [ ] I can launch a server with two or more routes using Flask
- [ ] I can receive and handle query strings and form POST respectively
- [ ] I can make a JSON response by returning a dictionary
- [ ] I can trace a request by comparing the browser’s Network tab with the server log
- [ ] I can explain code where XSS occurs and its principle (code-data mixing)
- [ ] I can demonstrate blocking XSS with escape
- [ ] Mission: I completed the mini guestbook and the success/block contrast captures
6. Common Pitfalls & Fixes
Wall 1. I edited the code but the browser looks the same
Symptom: you clearly changed the return statement but the screen doesn’t change.
Cause: the server is running on old code. Either you ran it without debug=True, or restart detection failed.
Stop this practice server with Ctrl+C and rerun python app.py after saving the file. Keep debug=False. Verify you are editing the same file that the running process loaded.
Wall 2. “Address already in use” error appears
Symptom (output example — the message varies by environment): the server won’t start and says the port is in use.
Cause: a previously launched server is still alive (another terminal window, etc.).
Fix: find that window and press Ctrl+C. If you can’t find it, temporarily dodge by changing the port to 5001. Build the habit of always shutting down launched servers when practice ends.
Wall 3. Submitting the form gives “Method Not Allowed”
Symptom (measured 2026-09-09): pressing the submit button gives 405 Method Not Allowed.
The route did not allow POST. Declare methods=[“GET”, “POST”] when both are intended. Default GET handling also normally permits HEAD and automatic OPTIONS.
Fix: add methods to the decorator. And check the form tag is <form method="post"> too — these two come as a set.
Wall 4. from flask import escape gives an ImportError
Symptom (measured 2026-09-09, Flask 3.1.3):
ImportError: cannot import name 'escape' from 'flask'
Cause: escape was removed in Flask 2.3. Following older materials, you hit this wall.
Fix: import it with from markupsafe import escape. That’s exactly the safety-processing tool Flask uses internally.
Wall 5. Korean input turns into %EA%B8%B8 in the address bar
Symptom: entering Korean in the address bar turns it into a sequence of %, numbers, and letters.
Cause: not an error — a URL can only hold English letters, digits, and some symbols, so the rest gets converted to % encoding (percent encoding). The server decodes it on its own (in the measurement too, name=길동 was pulled out correctly as Korean text on the server).
Fix: leave it as is. If you want to confirm the value the server received, put print(name) inside the route and watch the console.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Routing | A declaration connecting a URL to a function — a web server’s essence |
| GET / POST | Retrieve a representation / request processing; query and body placement are separate properties |
| Query string | ?name=value — parameters attached after the URL |
| Status code | 200 success / 404 no such address / 405 method not allowed |
| XSS | A vulnerability where input executes as HTML grammar — code-data mixing |
| Escaping | An output defense converting < → <-style non-executable characters |
| Dev/production server | app.run() is for development only — real services use a separate server |
Today’s Code
| Code | What it does |
|---|---|
@app.route("/address") |
Connect an address to a function (routing) |
methods=["GET", "POST"] |
Specify allowed methods |
request.args.get("name", default) |
Read URL query parameters, regardless of the request method |
request.form.get("name") |
Read supported form-encoded request data, not arbitrary JSON |
return dictionary |
Auto-generate a JSON response |
escape(string) |
Encode for HTML text; not a universal XSS defense for every context |
app.run(host="127.0.0.1", port=5000) |
Start a localhost development server |
An Instinct More Important Than Commands
From today, having written a server yourself, you see the web with two faces. You know a parameter in the address bar arrives at one line of request.args, and you know what happens if that value gets loaded into HTML without escape. Every input arriving at the server may be contaminated — this sentence is web security’s first principle.
And even if the framework changes, the skeleton doesn’t. Django or FastAPI — routing, request objects, GET/POST stay the same, because HTTP doesn’t change. The five-line server you made today is a scale model of every web service.
Once every box is checked, Step 94 is complete.
ONE STEP FURTHER
Finished this lesson?
Check the completion criteria, then mark your progress.