Your Base URL Does Not Keep urljoin on the Same Origin
A trusted base URL does not authorize the destination produced by joining it to an untrusted reference. In this laboratory, a base on server A ends in /api/. Give urljoin() an absolute URL for server B, or a reference beginning with // and B's authority, and the joined URL points to B. An unguarded client then reaches B successfully. The HTTP response is valid; the missing control is the application's destination decision.
The Python 3.13 URL-joining documentation warns about this behavior. The repair is not to distrust every absolute reference or invent a replacement URL resolver. It is to distinguish resolution from admission, then enforce a declared origin policy before the first request.
This playbook uses a deliberately small ASCII grammar, CPython 3.13.5, and two owned HTTP/1.1 servers bound to 127.0.0.1 on different ephemeral ports. An independent reproduction of the displayed driver matched the supplied preparation ledger: two deliberately unguarded controls reached B, five guarded cases reached A, and nine guarded cases produced no arrivals at either server.
These are synthetic laboratory observations, not customer incidents or evidence of a complete server-side request forgery defense. A local 302 response is recorded without following its Location. DNS, proxies, TLS verification, address-range policy, redirect chains, and production retry behavior remain outside the experiment. The operational question is precise: was this first destination admitted before this client could send?
Locate the trust decision before the first request
Start by identifying the application boundary. The trusted base is configuration controlled by the application owner. The reference is data from a less-trusted source, even when its field name is path, resource, or next. Such a name does not constrain the characters the value actually contains. Validation must address the supplied value rather than its intended description.
Resolution answers which URL a reference denotes in the context of a base. Admission answers whether this application may contact that resolved destination. Transport performs the contact. Treat these as separate operations with separate evidence. Combining them into a helper called safe_url would hide the most important qualification: the policy implemented here is narrow and application-defined.
Preserve this order:
1. Check the raw strings against the declared grammar before parsing can discard unsupported syntax.
2. Parse the supported input and force validation of authority and port components.
3. Resolve the reference against the trusted base.
4. Parse and validate the resolved result, compare origins, and check the request-target restriction.
5. Pass only the admitted candidate to the supported transport.
There must be no speculative request between these steps. A preliminary fetch to discover the final URL would already cross the boundary the guard is supposed to protect. Likewise, logging a final destination after a successful response can diagnose misrouting, but cannot prevent the first misdirected request.
In the fixture, admit() contains no HTTP client operation. The guarded send exists only in the successful branch following its return. An expected ValueError records REJECT_BEFORE_SEND; it does not trigger a fallback request. This control-flow fact and the server logs provide complementary evidence. Neither a reassuring function name nor a client-side success message establishes that separation.
The first-request boundary also determines where to collect evidence. Record the admission outcome before the send, then associate the transport result with that candidate. Do not overwrite the first decision with a later HTTP status. A response can be acceptable while the destination was unauthorized, just as an authorized destination can return an unsuitable response.
Make the base's ownership explicit during review. A comparison against an attacker-controlled base merely establishes consistency between two attacker-controlled values. The laboratory's base is constructed from server A's actual listener, not taken from the tested reference.
Define origin separately from path containment
For the supported HTTP(S) URLs, the policy compares a normalized triple: scheme, hostname, and effective port. The effective port is the explicit numeric port when present; otherwise this specimen uses 80 for HTTP and 443 for HTTPS. RFC 6454's origin and comparison rules provide the model. They are not a promise that this small implementation covers every URI category discussed in that specification.
Two listeners on the same address can therefore represent different origins. In the recorded reproduction, A used port 39319 and B used port 45587. Their scheme and host matched, but their ports did not. Ownership of both servers makes the experiment controlled; common ownership does not make their origin triples equal.
Normalization here is limited to the comparison fields. It does not lower-case resource paths, decode the query, or claim that two whole URLs identify the same resource. Keeping the origin tuple separate from the request target prevents a destination-equivalence rule from silently becoming a resource-rewriting rule.
Path containment is a different predicate. With A's base ending in /api/, /status resolves from the root, while ../admin resolves outside the base directory. Both remain on A. Their admission is intentional, not a missed negative case. The policy never promises that all permitted request targets begin with /api/.
A route-level requirement would need a separate specification tied to the destination's routing and decoding behavior. Do not rename the origin check a path sandbox, or change the expected result for ../admin merely because its spelling looks suspicious. That would replace the declared contract rather than test it. Broader API security and validation foundations provide context; this article isolates one outbound decision.
Write the admission policy in testable terms
The raw character set is ASCII letters, digits, and ., _, ~, /, :, ?, &, =, +, and -. Only strings are accepted. The regular expression also permits an empty reference syntactically, although the fourteen-case suite does not separately qualify that input. When a scheme is supplied, it must parse as HTTP or HTTPS with an authority.
Spaces, controls, backslashes, userinfo notation, percent signs, fragments, Unicode input, and bracketed IPv6 literals are outside the raw grammar. Their rejection means unsupported input, not a universal judgment that each feature is unsafe. The host expression permits only lowercase ASCII letters, digits, dots, and hyphens after parsing, but is not a complete hostname validator.
IDNA conversion is not implemented or qualified. In particular, an already-ASCII label beginning xn-- is not specially rejected by that host expression. Do not describe this grammar as detecting every internationalized-domain representation. The actual transport remains restricted to literal 127.0.0.1; broader hostname interpretation requires a separately tested policy.
Build two owned loopback endpoints
Use an owned working directory and the recorded CPython version. The driver needs only the standard library. It constructs two HTTPServer instances with port zero, obtains their assigned ports, and serves each in its own thread. Both listeners bind specifically to 127.0.0.1, not all interfaces. No public endpoint, cloud metadata service, credential, token, cookie, customer record, or state-changing operation belongs in this laboratory.
The servers append each handled GET target to separate A and B lists. Their bodies are inert-A and inert-B. The handler supplies Content-Length and declares HTTP/1.1. Server A has one special response: /go-other returns 302 with a Location pointing to B. It does not perform the next request itself.
The Python 3.13 HTTP client interface supplies an explicit transaction: construct HTTPConnection, call request(), then getresponse(). Here get_once() reads the response, records its status and optional location, and closes the connection. It rejects non-HTTP schemes and hosts other than literal loopback before constructing the connection. This is an additional laboratory restriction, not HTTPS transport support.
The three-second timeout passed to HTTPConnection bounds applicable blocking operations, not the entire experiment as a single deadline. Reading the body without an application size limit is acceptable for these owned, fixed inert responses; it is not a qualified policy for arbitrary remote response bodies. Do not reuse the transport as a general fetcher.
For surrounding request/response concepts, the backend HTTP and request-handling foundations are relevant. Keep this fixture separate from a production service and do not add environment-dependent proxy configuration.
Save the complete driver below as urljoin_lab.py, then run:
python --version
python urljoin_lab.py > urljoin-admission-evidence.json
Run with assertions enabled: do not use -O or an optimization-setting environment variable. Python's assertion semantics explain why optimization would remove the harness checks. A file created by output redirection is not proof of success; require a successful process exit and a complete, reconciled JSON ledger.
import http.client
import json
import platform
import re
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urljoin, urlsplit, urlunsplit
# Deliberately narrow contract, NOT a general URL/SSRF sanitizer.
RAW = re.compile(r'[A-Za-z0-9._~/:?&=+-]*\Z')
HOST = re.compile(r'[a-z0-9.-]+\Z')
def checked_split(value):
if not isinstance(value, str) or not RAW.fullmatch(value):
raise ValueError('unsupported raw syntax')
p = urlsplit(value)
if p.scheme and (p.scheme not in ('http', 'https') or not p.netloc):
raise ValueError('unsupported or incomplete scheme')
if value.startswith('//') and not p.netloc:
raise ValueError('empty authority')
if p.netloc:
if p.username is not None or p.password is not None:
raise ValueError('userinfo forbidden')
if not p.hostname or not HOST.fullmatch(p.hostname) or p.netloc.endswith(':'):
raise ValueError('unsupported authority')
_ = p.port # Validate the port before any network operation.
return p
def origin(p):
if p.scheme not in ('http', 'https') or not p.hostname
raise ValueError('absolute HTTP(S) URL required')
return (p.scheme, p.hostname, p.port if p.port is not None
else (80 if p.scheme == 'http' else 443))
def admit(base, reference):
b = checked_split(base)
checked_split(reference) # Raw checks must precede normalization.
p = checked_split(urljoin(base, reference))
if origin(p) != origin(b):
raise ValueError('origin mismatch')
if p.path.startswith('//'):
raise ValueError('unsupported request-target path')
return urlunsplit(p)
def get_once(url):
p = urlsplit(url)
if p.scheme != 'http':
raise ValueError('this owned-loopback lab sends HTTP only')
if p.hostname != '127.0.0.1':
raise ValueError('loopback fixture transport only')
c = http.client.HTTPConnection(p.hostname, p.port, timeout=3)
try:
target = p.path or '/'
if p.query:
target += '?' + p.query
c.request('GET', target)
response = c.getresponse()
return {'status': response.status, 'body': response.read().decode('ascii'),
'location': response.getheader('Location')}
finally:
c.close()
def main():
logs = {'A': [], 'B': []}
destination_b = ['']
def hander(label):
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *args):
pass
def do_GET(self):
logs[label].append(self.path)
redirect = label == 'A' and self.path == '/go-other'
data = ('inert-' + label).encode('ascii')
self.send_response(302 if redirect else 200)
if redirect:
self.send_header('Location', destination_b[0] + '/redirect-target')
self.send_header('Content-Length', str(len(data))
self.end_headers()
self.wfile.write(data)
return Handler
servers = [HTTPServer(('127.0.0.1', 0), handler(x)) for x in ('A', 'B')]
threads = [threading.Thread(target=s.serve_forever, daemon=True) for s in servers]
for t in threads:
t.start()
try:
a, b = [f'http://127.0.0.1:{s.server_port}' for s in servers]
destination_b[0] = b
base = a + '/api/'
naive = []
for ref in (
b + '/absolute-control',
b.removeprefix('http:') + '/authority-control',
):
joined = urljoin(base, ref)
naive.append({'input': ref, 'joined': joined, 'response': get_once(joined)})
assert len(logs['A']) == 0 and len(logs['B']) == 2
cases = [
('U1 relative', 'items?view=compact', True, '/api/items?view=compact'),
('U2 root relative', '/status', True, '/status'),
('U3 same origin outside base path', '../admin', True, '/admin'),
('U4 same-origin absolute', a + '/absolute', True, '/absolute'),
('U5 other authority', b.removeprefix('http:') + '/other', False, None),
('U6 other absolute origin', b + '/other', False, None),
('U7 different scheme', a.replace('http:', 'https:') + '/x', False, None),
('U8 malformed port', 'http://127.0.0.1:not-a-port/x', False, None),
('U9 leading control', '\n' + b + '/x', False, None),
('U10 backslash', '\\other\\x', False, None),
('U11 userinfo', a.replace('127.0.0.1', '[email protected]') + '/x', False, None),
('U12 encoding outside grammar', '/%2e%2e/admin', False, None),
('U13 fragment outside grammar', '/items#part', False, None),
('U14 redirect is not followed', '/go-other', True, '/go-other'),
]
ledger = []
for name, ref, allowed, expected_target in cases:
before = [len(logs[x]) for x in ('A', 'B')]
try:
candidate = admit(base, ref)
except ValueError as exc:
assert not allowed, (name, exc)
result = {'decision': 'REJECT_BEFORE_SEND', 'reason': str(exc)}
else:
assert allowed, name
result = {
'decision': 'ADMIT_FIRST_REQUEST',
'response': get_once(candidate),
}
assert logs['A'][-1] == expected_target
assert result['response']['status'] == (
302 if name.startswith('U14') else 200
)
delta = [len(logs[x]) - before[i] for i, x in enumerate(('A', 'B'))]
assert delta == ([1, 0] if allowed else [0, 0]), (name, delta)
ledger.append({
'case': name, 'reference': repr(ref),
'arrival_delta_A_B': delta, **result,
})
assert (
origin(checked_split('http://EXAMPLE.invalid:80/a'))
== origin(checked_split('http://example.invalid/b'))
)
assert (
origin(checked_split('https://example.invalid:443/a'))
== origin(checked_split('https://example.invalid/b'))
)
print(json.dumps({'python': platform.python_version(), 'base': base,
'naive_owned_controls': naive, 'cases': ledger,
'default_port_comparison': 'passed offline',
'arrival_logs': logs}, indent=2))
finally:
for s in servers:
s.shutdown()
s.server_close()
for t in threads:
t.join(timeout=3)
if name == '__main__':
main()Once execution enters the suite's protected block, its finally stops and closes both servers and joins their threads. The server shutdown contract requires the serving loop to run in another thread, as it does here. Server construction and thread startup precede that block; a failed startup is a failed run, not evidence that every acquisition failure is independently handled. Start a fresh process after investigating it.
Demonstrate authority replacement with two controls
The baseline deliberately bypasses admit(). It exists to show that the transport can reach B and that joining does not enforce the desired origin rule. It must remain visibly separate from the guarded suite. An unguarded baseline is not a fallback path for the eventual application.
First, the driver supplies B's full HTTP URL ending in /absolute-control. Next, it removes http: from B's origin and appends /authority-control, producing a reference beginning with //. In both cases, urljoin(base, ref) returns a destination on B, and get_once() sends one GET there. The reproduction returned status 200 and body inert-B for both controls.
The reference-resolution algorithm in RFC 3986 explains the structural distinction. A reference with its own authority can replace the base authority. The formal term for a reference beginning with // is a network-path reference: it can inherit the scheme while supplying the authority. It is not an ordinary path under /api/.
This is established URL-resolution behavior, not a new Python vulnerability or evidence of a parser defect. The failure lies in treating a successful transformation as authorization. Replacing the resolver with string concatenation would create another interpretation to maintain without supplying the missing trust decision.
Separate a syntactically resolved URL from an admitted URL
The following values come from the reproduced run. Its ports are observations, not fixed prerequisites:
Value | Resolved URL or origin |
Trusted base | |
Intended origin | ('http', '127.0.0.1', 39319) |
Absolute control result | |
Network-path control result | |
Both control origins | ('http', '127.0.0.1', 45587) |
The joined strings are usable by the laboratory transport. That says nothing about whether they meet A's same-origin policy. Their port mismatch is enough to reject them under that policy, even though both servers are owned and both return benign content.
After the two controls, the driver asserts that A has no arrivals and B has two. That initial count establishes a baseline for the guarded measurements. Later checks use changes in counts, so the deliberate B traffic cannot be mistaken for a guarded failure or conveniently erased.
Both controls use the same transport as the guarded admissions. That holds the sending mechanism constant while changing whether admission runs first. Keeping the comparison this small helps distinguish a policy repair from an unrelated change in client configuration, destination availability, or HTTP response handling.
Do not repair the counterexample by shutting B down. A connection failure would only show that B was unavailable. Keeping B reachable makes the missing admission check observable and ensures that subsequent zero-arrival results are not explained by an absent destination. The negative control must preserve the opportunity for the wrong request to succeed.
Check raw input before normalisation
The raw check protects the policy's view of what the caller actually supplied. It does not try to clean a broad URL language into something acceptable. A disallowed character produces a rejection before urlsplit() sees it, rather than being removed and silently accepted.
This order matters because the documented URL parsing behavior includes removal of leading C0 controls and spaces, and removal of newline, carriage-return, and tab characters anywhere in the URL. Checking only parsed components can therefore miss syntax the application intended to reject. U9 supplies a leading newline and is refused by the raw expression.
Keep the original representation in the evidence. The driver records repr(ref), making a newline visible as an escape rather than letting it split a log entry. U10 similarly preserves its backslashes. These are useful laboratory representations; an adapted logging system still needs a separate rule for sensitive data and retention.
U11 illustrates why the recorded reason matters. Its userinfo-shaped authority includes @, which the raw grammar already excludes. The result is unsupported raw syntax. The explicit username and password checks exist in checked_split(), but U11 does not independently exercise them. Describing that case as branch coverage for the later userinfo check would overstate the evidence.
U12 is equally specific. /%2e%2e/admin is not decoded, canonicalized, and then analyzed as a traversal attempt. It is rejected because % is unsupported. U13 fails because # is unsupported. Those cases cannot establish correctness for percent-decoding order, repeated encoding, fragment handling, or another client's interpretation of the same text.
Treat these exclusions as maintenance commitments. When a caller needs a legitimate feature outside the grammar, record that requirement and design an extension. Do not strip the troublesome character, rerun parsing, and describe the modified value as though it were the original request. That would change both semantics and the evidence associated with the caller's input.
An expanded grammar also changes which checks are reachable. Allowing @, for example, would require new tests of userinfo rejection rather than reliance on U11's old result. The current small suite provides a clear baseline, not automatic approval for additional URL features.
Validate the parsed URL and compare effective origins
Passing the raw character check means only that the string uses supported characters. It does not mean that its components form an acceptable URL. checked_split() therefore examines the parsed scheme, authority, hostname, and port. A supplied scheme without a usable authority is refused; an authority beginning with // cannot be empty under this policy.
The host test is deliberately limited. It excludes unsupported characters after parsing, but does not prove that every matching hostname is resolvable, registered, or suitable for contact. The trusted base and final origin comparison supply a different constraint. The literal-loopback transport restriction supplies another. Do not credit the host regular expression with work performed by those other controls.
The candidate is parsed again after joining. This keeps validation next to the result that will be admitted rather than assuming that separately acceptable inputs automatically produce an acceptable output. origin() then selects the parsed scheme and lowercase hostname and substitutes the scheme's default only when the port is absent.
A string-prefix check cannot replace that comparison. Matching a base including /api/ would reject permitted root-relative targets and U3 for the wrong reason. Comparing shorter textual prefixes can fail to establish component boundaries. Case and explicit default ports also create distinct serializations of the same origin. The tuple is the policy's comparison object; the serialized URL is its transport input.
The final p.path.startswith('//') check refuses a request-target form outside the chosen client contract. None of the fourteen guarded cases independently qualifies that branch. Its presence should be documented without inflating the case count or claiming complete branch coverage.
Handle parser errors before any socket operation
Reading .port is an intentional validation step. The parsed-port contract specifies that an invalid port can raise ValueError. U8 uses not-a-port, a spelling permitted by the raw character set, so its rejection happens at this later structural step rather than in the raw filter.
The guarded harness catches expected ValueError conditions around admit() and records REJECT_BEFORE_SEND. It does not ask the client to reinterpret the unchecked string. A parser error is not permission to remove a port, switch libraries, or try the original reference directly.
Keep configuration errors separate in a real integration: an invalid trusted base needs owner intervention, even though no request should be sent. Preserve fail-closed behavior while recording whether the problem came from caller data or application configuration. The fixture uses a known-valid base and does not simulate every deployment configuration fault.
Qualify the five permitted request cases
The positive cases matter as much as the rejections. A guard that blocks everything could prevent B traffic while failing the application's actual requirements. These five cases demonstrate useful admission under the declared same-origin rule, with the precise targets confirmed by A's handler.
Case | Reference form | Target received by A | Status | Delta A, B |
U1 | Relative path and query | /api/items?view=compact | 200 | [1,0] |
U2 | Root-relative path | /status | 200 | [1,0] |
U3 | ../admin | /admin | 200 | [1,0] |
U4 | Same-origin absolute URL | /absolute | 200 | [1,0] |
U14 | /go-other | /go-other | 302 | [1,0] |
U1 exercises the intended relative-path use: the base provides /api/, and the reference supplies a resource and query. The transport builds its request target from the parsed path plus the query. The assertion checks the full recorded target, not just whether A saw some request.
U2 removes the base-directory assumption. Its leading single slash selects a root-relative path, not a replacement authority. U3 goes further by using a parent-directory segment. In this setup, it resolves to /admin and is deliberately admitted because the origin stays unchanged. Neither case tests access control on a real administrative endpoint; both handlers return inert content.
U4 establishes that an absolute reference is not inherently a policy violation. Its destination is A, so a same-origin absolute URL satisfies the declared rule. Banning all absolute references would be a different, stricter interface contract. It would not explain or repair the origin comparison itself.
U14 is positive only for the first request. A's 302 is an expected laboratory response, not a claim that the complete business operation succeeded. Its role differs from redirected POST method and body evidence: this driver sends only GET and never performs the redirected request.
Preserve these distinctions in regression output. An admitted target with the wrong path, duplicate arrivals, an unexpected status, or any additional B arrival is not a successful reproduction. The checker needs both a correct admission decision and evidence that the selected transport executed the intended single request.
Prove that rejected inputs caused no arrivals
Each rejected case has a specific input, reason, and server-count delta. The table preserves the reproduced raw representations, including escape notation. Ports 39319 and 45587 belong to this run; use the current run's A/B relationship when reproducing it.
Case | Raw reference representation | Recorded rejection reason | Delta A, B |
U5 | '//127.0.0.1:45587/other' | origin mismatch | [0,0] |
U6 | origin mismatch | [0,0] | |
U7 | origin mismatch | [0,0] | |
U8 | 'http://127.0.0.1:not-a-port/x' | Port could not be cast to integer value as 'not-a-port' | [0,0] |
U9 | '\nhttp://127.0.0.1:45587/x' | unsupported raw syntax | [0,0] |
U10 | '\\other\\x' | unsupported raw syntax | [0,0] |
U11 | unsupported raw syntax | [0,0] | |
U12 | '/%2e%2e/admin' | unsupported raw syntax | [0,0] |
U13 | '/items#part' | unsupported raw syntax | [0,0] |
U5 and U6 demonstrate authority replacement rejected by policy. U7 changes the scheme while retaining A's host and numeric port; scheme equality is independently required. No HTTPS connection is attempted. U8 separates invalid port interpretation from unsupported raw characters. U9 through U13 establish only the exclusions actually exercised.
Make zero-arrival evidence part of the gate
Immediately before each attempt, the driver snapshots both list lengths. After the attempt finishes, it computes the difference for each server. Rejection requires [0,0]; admission requires [1,0]. An aggregate total could conceal a duplicate request in one case and a missing request in another. Per-case deltas make that ambiguity visible.
The server logs are independent of the client's decision string: they are written by request handlers, not by the admission function. They are not an independent packet-capture system. Precisely stated, the observations show no handled GET arrivals at either owned endpoint for rejected inputs. Combined with the code path that never calls transport on rejection, they support the fixture's pre-send boundary.
Do not extend that observation to arbitrary background traffic, raw socket attempts, other destinations, or an asynchronous client not present here. This driver issues requests sequentially and reads each admitted response before checking its delta. A different concurrency model would require explicit synchronization and attribution rather than copying the list-length technique unchanged.
Review unexpected deltas before accepting any aggregate. [0,1] means B handled a request; [2,0] means more A traffic than this single-send contract expects; [0,0] for a permitted case means the positive path was not demonstrated. These are acceptance interpretations of possible observations, not additional results claimed for this run.
A rejection message alone is weaker evidence. It could be emitted after a request had already escaped. Conversely, an empty destination log without a working positive control could mean a broken server. Keeping both positive admissions and the reachable B baseline makes the zero-arrival result meaningful within the stated setup.
Keep redirect handling outside the first-request approval
U14 admits /go-other on A. A responds with status 302, body inert-A, and a location pointing at B's /redirect-target. The reproduced ledger records all three response fields and an arrival delta of [1,0]. B's final log contains only the two earlier naive-control paths, not /redirect-target.
The relevant protocol rule is modest: RFC 9110's 302 semantics allow a user agent to use Location for automatic redirection. They do not require this laboratory's explicit transport to perform it. get_once() reads the header as data, returns it, and closes the connection. There is no second request() call driven by that header.
That distinction is why the outcome is called ADMIT_FIRST_REQUEST. It authorizes the selected first destination under this guard and transport contract. It is not a reusable permission token for URLs learned from the response. A trusted first server can still return information that lies outside the caller's destination policy.
A workflow that follows redirects would need another admission decision for each proposed next destination, along with a contract for the selected client's transformations and request handling. This article does not implement that workflow or qualify a redirect chain. Adding a loop around get_once() would be a new implementation requiring its own positive and negative evidence.
Keep the response outcome separate from the admission outcome. U14 passes the first-request fixture because the expected single request reaches A and the response is recorded without a follow-up. An application that requires a terminal 200 may still reject the response as unsuitable. Those conclusions can coexist: the destination decision was satisfied, while a separate response contract was not.
A final-URL check after automatic following is therefore not the proposed repair. Even when it notices B and reports failure, it has not prevented the B request. The evidence boundary must stay before the effect it governs.
For a production client review, identify exactly which layer can follow redirects: the immediate client call, an adapter, or surrounding application code. Do not infer no-follow behavior from this fixture's library choice after replacing its explicit request path with another abstraction. Until that behavior is separately demonstrated, preserve HOLD for the expanded workflow rather than borrowing U14's result.
Choose admission, rejection, repair, or hold
Operational outcomes should explain what happened and what may happen next. They should not collapse malformed input, policy mismatch, unsupported transport, and incomplete evidence into one vague network error. The following table describes the decision framework; the supplied driver emits only the two per-case admission decisions. Repair and hold are operator dispositions, not additional executed test outcomes.
Condition | Outcome | Required evidence or action |
Supported grammar, valid components, equal origin, supported transport | ADMIT_FIRST_REQUEST | Use the admitted candidate for one request; qualification expects [1,0]. |
Unsupported syntax, invalid port, or different origin | REJECT_BEFORE_SEND | Preserve the reason; qualification requires [0,0]. |
A genuinely needed URL feature falls outside the contract | REPAIR_AND_RERUN | Define an extension and add relevant positive and negative cases before use. |
Known unsupported transport is required | REPAIR_AND_RERUN | Keep the request unsent; qualify the new transport separately. |
Client behavior, logs, parser agreement, or execution is uncertain | HOLD | Suspend approval until the missing evidence is obtained. |
Observed arrival pattern contradicts the declared policy | HOLD | Investigate the discrepancy and requalify after a supported correction. |
Do not interpret a repair decision as temporary permission to send. While the policy is being extended, the unsupported input remains unsupported. A permissive fallback would bypass the very evidence needed to justify the change.
Similarly, an origin match does not override a known transport limitation. The offline HTTPS comparison is useful structural evidence, but get_once() still refuses HTTPS. Switching to a TLS-capable client requires a new transport qualification, not a different label on the same result.
Keep policy rejection distinct from transport failure
A connection refusal, timeout, or response-read failure after admission is a transport event. It does not retrospectively become a raw-syntax rejection, and it does not authorize another origin. Record the successful admission decision and the unsuccessful transport outcome separately.
The harness catches ValueError from admit() before entering the send branch. It does not convert failures inside get_once() into a completed rejection row. An unexpected exception interrupts the run and enters cleanup; a missing complete ledger is evidence to investigate, not a matrix pass.
This timing also distinguishes the guard from response-contract checks after a health request. A health response can require its own status, body, and redirect policy. Those checks address what came back after contact. The origin guard addresses whether this contact should begin.
Make these fields explicit in incident review. A single label such as “request failed” cannot tell the owner whether to fix caller input, trusted configuration, transport availability, or evidence collection. Precise outcomes reduce the temptation to resolve uncertainty by trying an unchecked destination.
Reconcile the complete fixture ledger
The displayed driver was independently executed on CPython 3.13.5 with optimization disabled in an x86-64 Linux environment. The process exited successfully with no stderr output. Its formatting-only line wraps preserved the original driver's parsed Python structure. The reported counts below belong to that reproduced execution, not merely to a syntax check.
The preparation brief separately reported the same fourteen guarded outcomes and two naive controls. That supplied account remains a distinct evidence source. The independent run provides a new JSON record against which the article's concrete ports, reasons, statuses, and arrival lists were checked.
Evidence group | Count or observed result | Interpretation |
Deliberately naive controls | 2, both reaching B | Unguarded joining does not enforce A's origin. |
Guarded admissions | 5, each [1,0] | U1, U2, U3, U4, and U14 reached only A. |
Guarded rejections | 9, each [0,0] | U5 through U13 caused no fixture arrivals. |
Guarded cases total | 14 | Five admissions plus nine rejections. |
Final arrivals | A: 5; B: 2 | B contains only the deliberate baseline requests. |
Offline normalization assertions | 2, both passed | HTTP case/default-port comparison and HTTPS default-port comparison. |
Do not add the offline assertions to the network-case count. The HTTP assertion combines hostname-case equivalence with explicit-versus-implicit port 80. The HTTPS assertion compares explicit-versus-implicit port 443. Neither invokes get_once(), resolves the .invalid host for a request, or demonstrates TLS support.
The final A paths are /api/items?view=compact, /status, /admin, /absolute, and /go-other. B contains /absolute-control and /authority-control. That reconciliation is stronger than stating that “all tests passed” without identifying the observations and their scope.
When rerunning, retain the new base and each raw reference together with the result. Copying the old numeric ports into a new expected ledger would confuse an incidental operating-system allocation with the tested property. Likewise, a changed diagnostic string should be investigated by failure stage; a wording difference alone does not establish that a request was sent.
Keep four evidence classes distinct when extending the article or implementation: documented contracts, supplied preparation observations, independently reproduced observations, and unexecuted expectations. Results for another interpreter build, parser, client, grammar, or network configuration belong in the last class until actually tested.
The documentation links identify the Python 3.13 branch, which can change independently of the recorded 3.13.5 runtime. This is a reproducibility baseline, not a recommendation to deploy that historical patch release. The experiment measures neither performance improvement nor incident-prevention rates, and its case count supplies no statistical estimate of real-world protection.
Repair an application without expanding the promise
Inventory the places where trusted bases are combined with references from less-trusted sources. Include references received through API responses or stored records, not only direct form input. The question is whether a value can influence an outbound destination without passing the intended admission boundary.
Write the required policy before changing the code. A same-origin rule, a route restriction, and an explicit set of multiple origins are different contracts. The specimen implements only the first, with its additional grammar and transport restrictions. Do not broaden it by editing production allowlists merely to make a failing example pass.
Place admission immediately before the selected client's first request. Send the returned candidate, not a reconstruction from the original reference in another layer. Any transformation after admission needs a demonstrated invariant: the destination interpreted by the client must still be the one the policy approved.
Keep the failure path closed. Unsupported input must not be retried through another parser, silently repaired, or forwarded unchanged after a caught exception. Classify a necessary feature expansion as REPAIR_AND_RERUN; classify uncertain behavior as HOLD.
When changing clients, reproduce the relevant admission and arrival tests with the actual configuration that will issue requests. Check request-target construction and explicit redirect behavior rather than assuming that every library agrees with urllib.parse. Python's documentation itself distinguishes practical parsing from universal standards compliance, so shared names such as “URL” are not an interoperability proof.
Retain the negative cases after repair. A new successful relative request does not establish that a different authority is still blocked. Review the diff in grammar, comparison, transport, and evidence collection together. A correction is applied only when the revised implementation is executed and its observed outcomes meet the intended contract; a proposed patch or green syntax check is insufficient.
Document the security boundaries that remain
The origin triple identifies a URL-level relationship. It does not pin a hostname to a particular network address or establish which address a future connection will use. DNS rebinding and address-range allowlisting therefore remain separate responsibilities. The loopback fixture deliberately avoids exercising either problem.
Proxy behavior is also unqualified. This driver directly creates HTTPConnection for literal loopback, never configures a tunnel, and does not implement proxy discovery. A different client or surrounding environment can introduce a different transport path. The appropriate owner must document that path and test it; the absence of proxy logic here is not evidence about every application deployment.
HTTPS introduces another boundary. Applying effective port 443 in an offline tuple comparison does not validate a certificate or authenticate a server. HTTP's certificate-verification requirements address that separate identity check. The related separate TLS hostname-verification gates belong to transport qualification, not this HTTP-only experiment.
Path restrictions also remain separate. U3 is a useful reminder that an origin can contain resources outside /api/. A future path policy needs its own supported representations and alignment with server routing. Do not infer path authorization from origin equality or from the fact that the local handler returns harmless content.
Assign clear owners. Application security owns the admission grammar and intended origin rule. The client owner owns transformations, redirect settings, and any retry wrapper. Network and platform owners own address selection, routing, proxy configuration, and transport identity. Observability owners must preserve enough evidence to connect a decision with the request actually performed.
Avoid a universal SSRF guarantee
The supported conclusion is limited to the declared inputs, code path, interpreter, and owned transport. The nine rejected cases produced no handled arrivals, and the five permitted cases produced the expected single A arrival. That does not constitute complete SSRF prevention, exhaustive parser coverage, or proof that every matching string is safe to use in production.
General HTTP-client retries are outside the experiment. Retrying an operation needs its own resilience policy; changing its destination requires another admission decision. Neither a timeout nor pressure to restore availability justifies converting REJECT_BEFORE_SEND or HOLD into permission.
In later adaptations, evidence records may contain sensitive query data even though this fixture does not. Minimize retained data and apply the organization's access and retention rules. Keep the security promise small enough that its owners can explain exactly what was checked, what was observed, and what still needs separate qualification.
Develop stronger application-security judgement
A transformation primitive is not an authorization primitive. That distinction is the central engineering lesson: resolve the reference, enforce the declared destination rule before transport, and retain evidence that distinguishes rejection from an unsuccessful send. Positive controls prevent a reject-everything implementation from masquerading as a working policy; reachable negative destinations prevent unavailability from masquerading as protection.
For broader grounding, review Refonte Learning's Cybersecurity Program. Its official page describes three months at 12–14 hours per week and lists OWASP, threat modeling, application-security testing, network and cloud security, secure CI/CD, incident response, and security workflow automation. It also describes mentoring and practical work, with a potential internship rather than an employment guarantee. These are relevant foundations, not evidence that urllib.parse, http.client, or this exact lab is taught.
