Q/Valid, Q/Utils: fail closed when Q/internal/secret is not configured - #40
Q/Valid, Q/Utils: fail closed when Q/internal/secret is not configured#40zattak1 wants to merge 1 commit into
Conversation
Internal request signing is fail-open when "Q"/"internal"/"secret" is unset,
in two different ways.
1. Q_Valid::signature() returns TRUE for every payload:
if (!isset($secret)) {
return true;
}
It is a validator, and it reads "no key" as "valid". Everything built on it
is an internal gate -- handlers/Q/config/validate.php, which writes config
onto the machine; Streams::invites(); Media/callCenter -- so an install that
has not set the secret accepts forged internal requests while looking
healthy. All three of those call sites pass $throwIfInvalid = true, so the
rejection surfaces as the existing Q_Response::code(403), with
Q_Exception_MissingConfig naming the key at fault rather than blaming the
caller for a local misconfiguration.
2. Q_Utils::signature() and Q_Utils::sign() fall back to
generateLocalSecret(), a sha256 of gethostname(), PHP_OS, APP_DIR and
/etc/machine-id (or the Windows MachineGuid). Every component is public or
identical across hosts deployed from the same image, so it is a secret in
name only, and callers cannot tell they were handed one instead of the
configured key. It also differs between the PHP host and the Node host, so
PHP <-> Node internal signing silently never verifies.
Rejecting on the verifying side is only safe together with failing loudly on
the signing side -- otherwise a silent hole is traded for a silent lockout:
sign, hand out something no peer can verify, and see no error anywhere. So both
halves move together, and generateLocalSecret() is deleted rather than left
unused.
Q_Utils::requireInternalSecret() also treats the empty string and the
"TODO: CHANGE TO SOME RANDOM STRING, ..." literal that local.sample/app.json
ships as unconfigured. That literal is a fixed value published in every copy of
this repository, so an install that keeps it is not weakly configured -- it
holds a secret every attacker already has, and can therefore sign.
platform/classes/Q/Utils.js carries the identical fallback in signature(),
sign() and validate(), whose docblock advertised "Returns true if secret is
empty". Fixed the same way so the two runtimes agree.
Behaviour with a secret configured is unchanged: every path that passes an
explicit $secret, and every path reading a real configured secret, is untouched.
|
The The placeholder detection is right too. A What I'd like changed is removing
The distinction I'd draw is between producing a signature and accepting one. Accepting an unsigned request must fail closed, always — that's this PR's real finding. Producing a signature, or verifying an artifact we issued ourselves, can degrade to a machine-local key without letting anyone in, and that's what keeps a misconfigured install serving instead of hard-erroring at the door. Concretely, what I'd merge:
I'd also take a Happy to take this as a rewrite whenever you have time, or split it: the |
|
Split as suggested:
On the distinction you drew — producing vs. accepting — agreed, and it is a better line than the one this PR drew. The runtime throw made sense against a |
Q/Valid, Q/Utils: fail closed when Q/internal/secret is not configured
Internal request signing is fail-open when "Q"/"internal"/"secret" is unset,
in two different ways.
Q_Valid::signature() returns TRUE for every payload:
It is a validator, and it reads "no key" as "valid". Everything built on it
is an internal gate -- handlers/Q/config/validate.php, which writes config
onto the machine; Streams::invites(); Media/callCenter -- so an install that
has not set the secret accepts forged internal requests while looking
healthy. All three of those call sites pass $throwIfInvalid = true, so the
rejection surfaces as the existing Q_Response::code(403), with
Q_Exception_MissingConfig naming the key at fault rather than blaming the
caller for a local misconfiguration.
Q_Utils::signature() and Q_Utils::sign() fall back to
generateLocalSecret(), a sha256 of gethostname(), PHP_OS, APP_DIR and
/etc/machine-id (or the Windows MachineGuid). Every component is public or
identical across hosts deployed from the same image, so it is a secret in
name only, and callers cannot tell they were handed one instead of the
configured key. It also differs between the PHP host and the Node host, so
PHP <-> Node internal signing silently never verifies.
Rejecting on the verifying side is only safe together with failing loudly on
the signing side -- otherwise a silent hole is traded for a silent lockout:
sign, hand out something no peer can verify, and see no error anywhere. So both
halves move together, and generateLocalSecret() is deleted rather than left
unused.
Q_Utils::requireInternalSecret() also treats the empty string and the
"TODO: CHANGE TO SOME RANDOM STRING, ..." literal that local.sample/app.json
ships as unconfigured. That literal is a fixed value published in every copy of
this repository, so an install that keeps it is not weakly configured -- it
holds a secret every attacker already has, and can therefore sign.
platform/classes/Q/Utils.js carries the identical fallback in signature(),
sign() and validate(), whose docblock advertised "Returns true if secret is
empty". Fixed the same way so the two runtimes agree.
Behaviour with a secret configured is unchanged: every path that passes an
explicit $secret, and every path reading a real configured secret, is untouched.