Skip to content

Fail closed when a route's auth policy cannot be built - #163

Merged
mgrossmann merged 2 commits into
mainfrom
issue-161-res-policy-fail-closed
Aug 9, 2026
Merged

Fail closed when a route's auth policy cannot be built#163
mgrossmann merged 2 commits into
mainfrom
issue-161-res-policy-fail-closed

Conversation

@mgrossmann

@mgrossmann mgrossmann commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #161

The defect

parse_kv_tail() strdup'd both RES=class:resource strings unchecked. A NULL
resclass makes both RACF gates in auth_gate() (src/httppc.c:376, :394)
false, so the route's resource check silently vanishes and the request falls
back to the global LOGIN policy. A NULL resname alone still forces
authentication at :376 but the authorize call at :394 needs both — the
request authenticates and is then never checked against the resource. Neither
case said anything.

Why "skip the route" is not the fix

An unregistered route does not disappear — its requests are served under the
global LOGIN default. For a MOD= route that lands on 404 (closed), but for a
LOC= prefix under LOGIN NONE it hands out the whole subtree the RES= was
meant to protect. So skipping is the fail-open the issue describes, and the
only strictly closed answer is to refuse to start. httpd.c:94 already
implements that path (HTTPD404E → close listener → quit).

The change

  • RES= keeps resclass/resname both-or-neither: if either strdup()
    fails, both are dropped, resattr is cleared, the policy is marked failed
    (HTTPD418E), and the loop breaks so a later RES= on the same line cannot
    clear the failure. apply_policy() is the only writer of those two fields, so
    the invariant holds for every registered route.
  • A route that carried a binding policy and could not be registered issues
    HTTPD419E and sets HTTPD_FLAG_CFGERR (new, 0x08, a free bit — no HTTPD
    offset moves).
  • http_config() checks that flag right after the Parmlib read and returns 8
    with HTTPD420Ebefore do_bind(), so the listener is never opened and
    httpd.c's cleanup is a no-op.
  • Binding = RES=, or AUTH=FORM/BASIC. AUTH=NONE and policy-less routes
    stay warnings: the fallback can only be stricter than what they asked for.

Scope: two arms wider than the issue

The issue names the strdup pair. Two neighbours in the same functions are the
identical defect and are fixed with it — flagging them rather than leaving them
in the diff for a reviewer to find:

  1. http_add_cgi() returning NULL (calloc/strdup/array_add OOM inside
    httpacgi.c). Reported as HTTPD035W/HTTPD416W with startup continuing —
    for a policy-bearing LOC= route that is the same fail-open.
  2. strdup(value) at the top of parse_mod/parse_loc. The line cannot be
    tokenized, so whether it carried a policy is unknowable. Treated as if it
    did, which means any undippable route line is now fatal, including a
    policy-less MOD=HELLO /hello. That is a startup-behaviour change beyond the
    issue; the reasoning is that the parser genuinely cannot tell, and a region
    that cannot dup 40 bytes at startup is finished anyway.

Testing

No new test. The OOM path is not reproducible in a host test — there is no
allocator interposition on this toolchain, and a strdup link-override is
fragile on macOS. Building either an injectable allocator or a symbol override
into production code for this would be worse than the honest note.

Verified instead by:

  • make modules / make test clean under -Wall -Werror (cc370)
  • make test-host — 4 suites, 63 assertions, 0 fail
  • live on mvsdev (deployed, activated, restarted) — every configured route back
    in /.dsrv?target=MOD, mgr->func moved so it is genuinely the new module,
    and a temporary RES= probe route registered with both halves of the pair
    intact. Full evidence in the verification comment below.

The success path was restructured in both parsers (parse_loc went from an
unconditional to a conditional http_add_cgi, and both message blocks were
inverted), which the compiler cannot check — that live route-table check is what
covers it.

Docs

docs/configuration.md gains a paragraph under the AUTH=/RES= section
stating the rule: a route carrying an auth policy is registered or the server
does not start.

RES=class:resource strdup'd both strings unchecked. A NULL resclass made
both RACF gates in auth_gate() false, so the route's resource check
silently vanished and the request fell back to the global LOGIN policy;
a NULL resname alone still forced authentication but never authorized
the request against the resource. Neither said anything.

Skipping the route is not a safe fallback either: an unregistered route
does not disappear, its requests are served under the global LOGIN
default, and for a LOC= prefix under LOGIN NONE that is the whole
protected subtree served to anyone. The same applies to the two arms
next to it -- http_add_cgi() returning NULL, and the strdup() of the
line itself -- which dropped a policy-bearing route just as quietly.

Now: RES= keeps resclass/resname both-or-neither and marks the policy
failed if either strdup fails (HTTPD418E); a route that carried a
binding policy (RES=, or AUTH=FORM/BASIC) and could not be registered
issues HTTPD419E and sets HTTPD_FLAG_CFGERR; http_config() checks that
flag after the Parmlib read and returns 8 with HTTPD420E, before
do_bind(), so the listener is never opened. AUTH=NONE and policy-less
routes stay warnings -- the fallback can only be stricter. A line that
could not be tokenized is treated as policy-bearing, since the parser
cannot tell.

Fixes #161
@mgrossmann

Copy link
Copy Markdown
Contributor Author

Adjacent fail-open found while reading parse_loc(), deliberately left out of this PR: #164 — a LOC= line with the path omitted is dropped with a warning and the server starts, so the subtree it was meant to protect is served under the global LOGIN default. Same shape as #161 but reachable from a typo, no allocation failure needed. It is a malformed-config policy question rather than an allocation one, so it gets its own decision.

@mgrossmann

Copy link
Copy Markdown
Contributor Author

Live verification (mvsdev, MVS 3.8j)

Deployed, activated via tests/jcl/httpdact.jcl (both IEBCOPY steps RC 0000), restarted.

New module is really the one running/.dsrv?target=MGR, mgr->func 00097F1800098920.

Success path intact. All six configured routes register exactly as before; startup reaches HTTPD001I Server is READY with no HTTPD418E/419E/420E, i.e. the new abort is not armed by a healthy Parmlib:

HTTPD036I Module MVSMF registered for /zosmf/info
HTTPD036I Module MVSMF registered for /zosmf/services/authenticate
HTTPD036I Module MVSMF registered for /zosmf/*
HTTPD036I Module HTTPDSRV registered for /.dsrv
HTTPD036I Module HTTPDM registered for /.dm
HTTPD036I Module HTTPDMTT registered for /.dmtt

RES= acceptance path. The live Parmlib carries no RES= route, so that arm would otherwise have gone untested — an inverted both-or-neither check would refuse every RES= route and the server would not start at all, and nothing here would have noticed. Added a temporary probe protecting a path that does not exist:

LOC=/.verify161/* AUTH=BASIC RES=FACILITY:HTTPD.VERIFY161

HTTPD417I Location /.verify161/* registered, and /.dsrv?target=MOD decoded it as:

field value
+0014 auth 3 BASIC
+0015 resattr 02 READ — set, not the assumed default
+0018 resclass "FACILITY"
+001C resname "HTTPD.VERIFY161"

Both halves of the pair present. The gate then behaved as configured: unauthenticated GET /.verify161/x401, authenticated → 404 (stage 2 passed because FACILITY HTTPD.VERIFY161 has no profile, which is #137's documented behaviour, and the file does not exist).

Probe line removed, SYS2.PARMLIB(HTTPPRM0) restored byte-for-byte, restarted again. Server is READY on the original config, /.dsrv and /zosmf/restfiles both 200, /.verify161/x back to a plain 404.

HTTPD418E/419E can carry raw Parmlib text: 419E is passed the whole
untokenized line when strdup() of it fails, and 418E the RES= operand.
Truncate both, matching the %.40s parse_line() already uses for
HTTPD020W.

HTTPDSRV's httpd->flag decode lists every bit defined in httpd.h, so
add CFGERR to it -- a server that can answer the request never has it
set (it aborts before the listener is bound), but a bit missing from
that table is how #146 and #155 started.
@mgrossmann

Copy link
Copy Markdown
Contributor Author

Scope note on what was verified where: the live run above was on d33fdf1. 7248061 came after it and is bounds + display only — %.40s truncation on the two new WTOs (HTTPD419E is handed the raw untokenized Parmlib line, HTTPD418E the RES= operand), and CFGERR added to HTTPDSRV's httpd->flag decode so that table stays complete against httpd.h. Neither touches route registration, so the live evidence still stands for the logic; both rebuild clean under -Wall -Werror with host tests green. Not redeployed — it would cost another STC restart for a format string.

@mgrossmann
mgrossmann merged commit 31ea31e into main Aug 9, 2026
1 check passed
@mgrossmann
mgrossmann deleted the issue-161-res-policy-fail-closed branch August 9, 2026 21:41
mgrossmann added a commit that referenced this pull request Aug 10, 2026
HTTPD404E printed the CONFIG= parm, which http_config() has ignored
since the Parmlib migration -- and which the shipped STC procedure
does not pass at all, so the one message naming what failed to process
always read "processing (null)". After #163 and #165 gave the parser
real reasons to refuse a startup, that message got a lot more
visible.

The configuration's real identity is the HTTPPRM DD, and the PROC
allocates it as &D(&M), so which member is in effect is a startup
choice an operator can genuinely need to confirm. parmlib_name()
resolves it from the DSAB -> TIOT entry -> JFCB, read-only pointer
chasing with no SVC and no allocation, falling back to the DD name if
any link is missing. It is reported before parsing, so a later error
is already attributed:

    HTTPD022I Configuration from SYS2.PARMLIB(HTTPPRM0)

HTTPD404E drops the operand accordingly.

CONFIG= is now answered rather than swallowed. Silently ignoring it is
worse than not accepting it -- an operator who passes it believes it
took effect, and migration.md still tells 3.x users to remove theirs,
so a leftover is exactly what this catches. HTTPD024W says it did
nothing and names the mechanism that does.

Fixes #166
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RES= authorization fails open when strdup() returns NULL: pol->resclass/resname parsed unchecked

1 participant