Your hosted browser provider's key is a masked pipeline variable, yet the endpoint address prints readably. Why?
answer
- value matching is the wrong control here
- match the value, or replace the component
- encoding can break the byte match
- a hit still leaves the account name
- no user info parsed is not no user info
basics
~20 sBecause value matching is the wrong control for a credential inside a URL: assembly can re-encode the key so the masker matches nothing, and a clean match still leaves the account name. Redact the address by component instead.
solid answer
~50 sA **value-matching** masker rewrites the exact byte sequence it was handed, and that fits this credential badly, because the key's destination is the user-info component of an address. Characters that would otherwise be structural there get percent-encoded during assembly, so the string that actually prints may not be the string the masker was given, and it has nothing to match. Even when the key survives verbatim and is caught, what remains still shows the account name and points at the component the secret sat in. The fix is **structural**: rebuild the address with its user-info component replaced by a fixed marker, and route every log line, report field and exception message you construct through that one function. Make it fail closed — an address whose authority the parser will not read reports no user info while still carrying the credential.
code
java · 35 lines// Every diagnostic path uses this; nothing else ever sees the raw address.
private static final String REDACTED = "<redacted endpoint>";
static String withoutUserInfo(URI endpoint) {
if (endpoint == null) {
return REDACTED;
}
if (endpoint.getUserInfo() != null) { // server-based: rebuild without it
try {
return new URI(endpoint.getScheme(), "***", endpoint.getHost(), endpoint.getPort(),
endpoint.getPath(), endpoint.getQuery(), endpoint.getFragment()).toString();
} catch (URISyntaxException unusable) {
return REDACTED;
}
}
// getUserInfo() == null is NOT proof there is none. An authority the parser will
// not read as server-based - an underscore in a service name is enough - leaves
// userInfo AND host null with the credential still in the string.
return hasNoUserInfo(endpoint) ? endpoint.toString() : REDACTED;
}
/** True only where the raw text positively shows no user-info component. */
private static boolean hasNoUserInfo(URI endpoint) {
String rest = endpoint.getRawSchemeSpecificPart(); // raw: nothing decoded away
if (rest == null) return false; // unreadable -> untrusted
if (!rest.startsWith("//")) return true; // no authority at all
int slash = rest.indexOf('/', 2), query = rest.indexOf('?', 2);
int end = slash < 0 ? rest.length() : slash;
if (query >= 0 && query < end) end = query;
return rest.lastIndexOf('@', end - 1) < 2; // '@' in the authority = user info
}
// URI.create("https://acct-name:[email protected]/hub") -> https://***@fleet.example/hub
// URI.create("https://acct-name:key@fleet_grid/hub") -> <redacted endpoint>
// (the naive guard returned that second one in full)go deeper
Know that the connection address contains the credential, and that printing the address prints the credential. When you add a log line about the endpoint, ask what exactly will be in it.
Be ready to explain the difference between matching a value and rewriting a component, and to say why the second one keeps working when the key has to be encoded to sit in a URL.
You are expected to find the sinks nobody wired a masker into — an exception message, a report field, a re-run hint — and to put a single redaction helper in front of all of them.
Decide where redaction is owned so it is not re-invented per suite, and set the expectation that a credential inside an address is handled by shape rather than by remembering to register a value.
Your charity donation-checkout suite runs against a hosted browser provider. The provider issued one account name and one long-lived key, and the client dials a connection address that carries both inside it. The key is registered in the pipeline as a protected variable. A run fails, somebody opens the output, and the whole address is sitting there in a stack trace, readable. ## Why matching the value is the wrong control here A **value-matching** masker is handed the secret and rewrites that exact byte sequence wherever it appears in the output. It knows the value and nothing about what carries it. That is a serviceable backstop for a token in a header. It fits this credential badly, because this credential's destination is the user-info component of a URL. Characters that would otherwise be structural in that component get percent-encoded on the way in, and different assembly paths encode different sets of them: one common Java assembly path encodes a slash and an at-sign while leaving a plus and a colon untouched, where an explicit URL encoder encodes all four. A key those touched is no longer the string the pipeline was told to look for, so the masker matches nothing and reports nothing — from its point of view the secret never appeared. This does not fire for every key: one whose only reserved characters are a colon or a plus goes in verbatim and is masked. Whether it fires depends on the key you were issued *and* on the assembly path your code took, and neither announces itself. ## What a clean match still leaves behind Suppose the key is plain alphanumeric and the masker does fire. The line becomes something of the form `https://acct-name:***@host/path`. Three things survive: - the **account name**, which identifies the tenant and is one half of the credential pair — replaced only if you registered it too, which platforms often decline for a value that short and that low in entropy; - the **address shape**, which tells any reader exactly which component held the secret; - the **host and path**, which say where the redacted value is accepted. So a masker firing is a partial result, not a clean one. That alone is a reason to redact structurally even when you believe the value is safe. | | value-matching | structural | |---|---|---| | what it is given | the secret | the address | | survives re-encoding of the key | no | yes | | hides the account name too | only the values you registered | yes | | needs to be told the secret | yes | no | | covers a sink it was never wired into | no | no | ## Redacting structurally on your own side **Structural** redaction is handed the *carrier* instead: parse the address, replace its user-info component with a fixed marker, emit the result. It knows the shape and nothing about the value. The move is small, and it belongs in one place: 1. Assemble the address once, hold it in a single variable, and pass the raw value nowhere but the client that dials it. 2. Write one function that takes the address and returns it with the user-info component replaced by a fixed marker. 3. Route every diagnostic path through that function: log lines, report metadata, the "re-run with" hint your harness prints, and the message of any exception you construct yourself. 4. Make that function fail closed, which is harder than it looks. ## The guard that eats the whole control The obvious first line is "if this address reports no user-info component there is nothing to hide, so return it as it is." That test is wrong, and it is wrong in the direction that leaks. A URL parser only fills in user info and host when it can read the authority as *server-based*. Give it an authority it will not read that way — an underscore in the name is enough, and a container service name is the everyday case — and it reports **no user info and no host** while the credential is still sitting in the string, plainly visible. Measured on a current JDK: `https://acct-name:[email protected]/hub` redacts to `https://***@fleet.example/hub`, while `https://acct-name:plainkey123@fleet_grid/hub` comes back whole. The redactor has handed the sink the credential and believes it redacted. So treat *unparsed* as *untrusted*: return your constant unless you have positively established there is no user info — by looking for an at-sign in the raw authority text yourself, for instance. Losing the host from one log line is a far smaller cost than printing the key. And note which branch deserves the paranoia: it is this one, not the rebuild's failure branch. Once a user-info component has parsed, the rebuild has every part it needs and does not throw. You cannot produce this class of address by assembly — hand an underscore host to the multi-arg `java.net.URI` constructor and it refuses outright. It arrives **parsed**: an address someone handed your suite whole, or one the far side sent back. That is exactly the surface the return path tells you to run through the same helper. ## The rule to carry away `java.net.URI.toString()` includes the user info verbatim. Anything that interpolates an address into a message — your own logger, an assertion failure, a shell trace of the command that launched the run — prints the credential unless something in between decided otherwise. Where a library builds the message for you, check what it actually does rather than assume, and check it on the awkward inputs. Selenium's `JdkHttpClient.maskUrlCredentials` replaces the user-info component before a request URI goes into a connection-error message, and its Grid node status offers a masked accessor that some distributor log lines use — while the rest of Grid reads the raw address. It also carries the null-user-info guard described above, so the most-copied version of this helper has the same hole. - Do not ask whether the key is masked; ask whether the address was redacted before the sink saw it. - Treat the assembled address as the secret, not the key inside it. - Assume every sink prints what it is given, and give it the redacted form. Redacting the printed form is one boundary. It does not follow the credential into the job's process environment, into an artefact your harness writes, or into anything the far side sends back — those are separate copies with separate fixes.
- Your harness redacts the address before logging it. What still holds an unredacted copy?The variable the client was handed, the job's process environment, and every child process that inherits it. Redaction is a property of one output path, not of the value, so anything that reads the environment directly or stringifies the address elsewhere is untouched by it.
- Why bother hiding the account name once the key itself is masked?The name identifies the tenant and is one half of the pair, so it narrows an attacker's search to a single account. It also signposts exactly which component of the address held the secret, which is free reconnaissance for anyone reading the log.
- How would you check that your redaction actually holds before shipping it?Assert on it, and pick the inputs that break it. Build an address from a key containing characters the assembly path encodes, and assert that neither the raw key nor any encoded form of it survives the redactor. Then do the one that matters: parse an address whose authority the parser will not read as server-based — a service name with an underscore is the cheap one — and assert the redactor still hides it, because that is the input that makes the URI type report no user info at all. Then assert the same over a captured log line from a deliberately failing run.
saying these in an interview costs you the question
- Registering the key as a protected variable makes the address safe to print
- Masking is a property of the value, so it follows it everywhere
- Percent-encoding is only about correctness and never affects redaction
- If the key is masked, printing the rest of the address costs nothing
- Every client masks credentials in a URL before logging it
- An address that reports no user-info component cannot be carrying one