Site Icon THEREiSLatest Posts · reading #006한국어

Title :

Secure Infrastructure and Secure Coding for Security #2 - Coding

Author
[Yahum]
Date
Tags

This post is translated from the Korean original. Read in Korean →

In part 1, I talked about infrastructure. I sketched out a structure that even admins can barely get into: servers hidden in a Private Subnet, external traffic received only by the ALB and WAF, and admins coming in only through the Bastion. But as I wrote at the end of part 1, no matter how tightly you lock down the infrastructure, beyond port 443 there’s ultimately the code we wrote. If you trust the requests that come through the front door, the walls you built earlier mean nothing.

In this part, I want to lay out what I pay attention to at the code level, based on the stack I actually use.

The stack I use these days

  • Frontend: Next.js (SSR)
  • Backend: Java Spring Boot, RESTful API
  • Authentication/authorization: Spring Security
  • DB access: MyBatis

I ended up back at MyBatis

I’ve tried just about every DB access technology. I’ve used JPA / QueryDSL, and I’ve even tried jOOQ with Flyway on top. But I ended up coming back to MyBatis. Maybe it’s because I’m an old-school developer.

It’s true that ORMs are convenient during development. But keeping entities in sync with the actual DB schema was more of a hassle than I expected, and once I started adding code generation or migration plugins, the pom.xml config got almost unmanageably complex. With MyBatis I write the SQL myself, so the queries that run are right there in front of me, and that actually put my mind at ease.

That said, writing SQL yourself also means you have to stop SQL injection with your own hands.

What is SQL injection

SQL injection is an attack that happens when a value the user entered is executed not as a “value” in an SQL statement but as part of the “command.” It’s an old attack, but it still regularly shows up under the injection category of the OWASP Top 10, and it still frequently appears as the cause of real personal data breaches.

The principle is simple. Say you built login handling like this.

// Code that concatenates user input directly into the SQL string
String sql = "SELECT * FROM users "
+ "WHERE login_id = '" + loginId + "' "
+ "AND password = '" + password + "'";

When a normal user enters hong as the ID, it works as intended. But if someone enters admin' -- in the ID field, the final SQL becomes this.

SELECT * FROM users WHERE login_id = 'admin' --' AND password = '...'

The single quote (') in the input closes the string, and the -- after it turns the rest of the condition into a comment. The password check disappears, and you can log in as the admin account without knowing the password.

Bypassing login is just the beginning. In the same way, you can append UNION SELECT to pull out member data from other tables wholesale, or, if the DB account has generous permissions, modify or delete data. In part 1 I hid the DB deep inside a Private Subnet, but with SQL injection the application server effectively connects to the DB on the attacker’s behalf, so network isolation can’t stop it. A request that passed through the front door borrows the application’s permissions and walks right into the DB.

The heart of the problem is that data and commands are mixed together in the same string. So the fix is to separate them. If you build the SQL skeleton first with PreparedStatement and bind user input only as “values” in the ? slots, then even if the input contains quotes or comment symbols, they won’t be interpreted as SQL syntax.

String sql = "SELECT * FROM users WHERE login_id = ? AND password = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, loginId); // Even admin' -- is treated as just a single "string value"
ps.setString(2, password);

(Of course, in practice you shouldn’t compare passwords in plain text in SQL; you should store them as hashes and compare them in the application. I’ll come back to this later.)

You also see code that responds by stripping quotes from input or blocking certain keywords, but there are so many ways around it that it can’t be a fundamental fix. Filtering is only a supplementary measure; the foundation is always parameter binding. And in MyBatis, the syntax that does this binding is #{}.

And this is just my opinion, but an application that allows SQL injection is a sin. The developer who built it should be punished. I mean it. Seriously. It’s a disaster that must never, ever happen.

SQL injection deserves punishment

#{} vs ${}, one character apart

It’s no exaggeration to say this is the single most important secure coding point in MyBatis.

<!-- Dangerous: concatenates the string directly into the SQL -->
SELECT * FROM users WHERE name = '${name}'
<!-- Safe: handled with PreparedStatement ? binding -->
SELECT * FROM users WHERE name = #{name}

${} inserts the value directly into the SQL string. If a user sends something like ' OR '1'='1 instead of a name, the query itself changes. #{} binds the value as a parameter, so whatever comes in is treated simply as a “value.” So the rule is simple. Anywhere user input can reach, always use #{}. No! Whether user input reaches it or not, always use #{}!

The problem is that there are places where you can’t use #{}. The classic example is the sort column. ORDER BY #{sort} is bound as a string value, not a column name, so sorting doesn’t work. That creates the temptation to use ${sort} here, and in that case, make it choose only from allowed values.

ORDER BY
<choose>
<when test="sort == 'name'">name</when>
<when test="sort == 'createdAt'">created_at</when>
<otherwise>id</otherwise>
</choose>

LIKE searches are another common place for mistakes. Instead of '%${keyword}%', build the string while keeping the binding, like CONCAT('%', #{keyword}, '%').

Why I use Next.js: tokens go in server cookies, not the browser

I use Next.js partly for SSR, but from a security standpoint, the bigger reason is that I can keep user tokens somewhere browser JavaScript can’t touch.

Here’s the structure. The backend (Spring Boot) provides purely a RESTful API, and the browser never calls Spring Boot directly. The browser talks only to the Next.js server, and the Next.js server calls the backend API holding the token. The Next.js server hands the token down as an HttpOnly cookie.

app/api/login/route.ts
import { cookies } from 'next/headers';
export async function POST(req: Request) {
const body = await req.json();
// API_URL has no NEXT_PUBLIC_ prefix, so it can only be read on the server
const res = await fetch(`${process.env.API_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
return Response.json({ message: 'Login failed.' }, { status: 401 });
}
const { accessToken } = await res.json();
(await cookies()).set('access_token', accessToken, {
httpOnly: true, // JS can't read it via document.cookie
secure: true, // Sent only over HTTPS
sameSite: 'lax', // By default, not attached to requests initiated from other sites
path: '/',
maxAge: 60 * 30,
});
return Response.json({ ok: true });
}

If you put the token in localStorage, a single XSS vulnerability lets one line of script send the token out. If you keep it in an HttpOnly cookie, you can at least stop the token itself from being stolen.

Of course, that’s not the end of it. I started out as a frontend developer, so I believe frontend security has to be locked down with an obsession that would drive a madman madder. And the frontend still gets breached anyway. Read through the points below.

  • XSS doesn’t go away. They just can’t steal the token; sending requests from within the page while pretending to be the user is still possible. React escapes output by default, but anywhere you use dangerouslySetInnerHTML must go through sanitization.
  • If you use cookies, you have to care about CSRF. SameSite blocks a good portion of it, but for requests that change state, it’s safer to add a CSRF token or an Origin check as well.
  • Every NEXT_PUBLIC_ environment variable is exposed to the browser. If you habitually slap NEXT_PUBLIC_ on API keys or internal URLs, they get baked right into the build output where anyone can see them. Damn environment variables. You can’t get rid of them, but you can’t use them carelessly either. Only put in information that doesn’t matter if it’s exposed. And think about a million times whether it’s really okay for that information to be public.
  • A Server Action is ultimately a public endpoint too. Hiding a button on the screen doesn’t block the call. Check authentication and authorization every time inside Server Actions as well.

Spring Security: authentication is a given, authorization is what matters

On the Spring Boot side, Spring Security handles user authentication and authorization checks. The basic principle is close everything except what you’ve explicitly opened.

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// This API is called only by the Next.js server, not browsers, so CSRF is handled on the Next.js side
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

Most people get this far just fine. But where real incidents happen a lot is the next step: “Can a logged-in user see someone else’s data?” Just because the user calling /api/orders/1001 is logged in and has the USER role doesn’t guarantee that order 1001 belongs to them. The IDOR (Insecure Direct Object Reference) attack, where you just change the number in the URL to look up someone else’s order, is still a common cause of personal data leaks.

In my experience, distrusting every request that comes up from the frontend is a basic skill. You have to doubt, and doubt again: “Did you really send a legitimate request?”

So lookup queries always include an owner condition as well. Here, userId is taken from the authentication info (SecurityContext), not from a request parameter.

<select id="findOrder" resultType="Order">
SELECT *
FROM orders
WHERE id = #{orderId}
AND user_id = #{userId}
</select>

Beyond that, here’s what I take care of by default on the Spring side.

  • Request DTOs use @Valid and Bean Validation to re-validate the format and length of input on the server. Frontend validation exists only for user convenience.
  • Passwords are stored only as one-way hashes, with something like BCryptPasswordEncoder.
  • Error responses never send stack traces or SQL statements as is.

Keep secrets out of source code

No matter how well you write the code, it’s useless if a DB password or API key is baked into it. Things like a password you dropped in briefly for testing, a server access key written into a deploy script, or a Secret Key written as is in application.properties.

What makes hardcoded secrets scary is that Git never forgets. Even if you delete it from the code later, it stays in the commit history. Anyone with access to the repository can see it, and if the repository is ever made public by mistake, it’ll quickly be found by bots that roam public repositories harvesting keys. So the right response to a secret that’s been committed even once isn’t “delete it” but “revoke it and issue a new one.”

There’s one principle. There must not be a single secret in the source code. Secrets are held by Jenkins Credentials during build and deploy, and by Kubernetes Secrets at runtime.

Jenkins Credentials: secrets for build and deploy

Jenkins has a Credentials feature for storing secrets separately. You register them, and pipelines retrieve them only by ID, so even if you push the Jenkinsfile to Git, the secrets aren’t exposed. I split them by purpose like this.

  • SSH Username with private key: SSH keys for accessing Git repositories or connecting to deploy servers
  • Secret text: Single values like API tokens or Secret Keys
  • Username with password: Container registry accounts, etc.
  • Secret file: Things you need as a whole file, like kubeconfig
pipeline {
agent any
stages {
stage('Push Image') {
steps {
withCredentials([usernamePassword(credentialsId: 'registry-account',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS')]) {
// Single quotes: let the shell, not Groovy, interpret the variables
sh 'echo "$REG_PASS" | docker login -u "$REG_USER" --password-stdin registry.example.com'
}
}
}
stage('Deploy') {
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh 'ssh -o StrictHostKeyChecking=yes deploy@10.0.1.10 "./deploy.sh"'
}
}
}
}
}

Values retrieved inside a withCredentials block are masked as **** in the build log. But there are things you shouldn’t rely on this alone for.

  • Wrap sh commands in single quotes. If you use double quotes like sh "... ${REG_PASS}", Groovy interpolates the value into the string first, and the secret can be exposed in places like process arguments. Jenkins shows a warning in this case too.
  • Log masking isn’t perfect. If you convert a value to base64 or print it one character at a time, it bypasses the masking and gets printed as is. Don’t print secrets in the first place.
  • Credentials have permissions too. Don’t register them as Global; split the scope by folder or project, and make production Credentials usable only by production deploy jobs.
  • Jenkins itself becomes a target. It’s where all the secrets gather, so put Jenkins in a Private Subnet like the servers in part 1 and minimize who can access it.

Kubernetes Secret: secrets used at runtime

DB passwords and external API keys that the application uses while running go into a Kubernetes Secret and are injected into the Pod as environment variables or files. Nothing is left in the container image or the source code.

# deployment.yaml (excerpt)
containers:
- name: api
image: registry.example.com/api:1.0.0
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: api-secret
key: db-password

Pushing a Secret YAML file to Git is no different from hardcoding, so the Secret is created by Jenkins at deploy time, pulling the value from Credentials. This way, secrets are managed in just one place: Jenkins Credentials.

withCredentials([string(credentialsId: 'db-password-prod', variable: 'DB_PASSWORD'),
file(credentialsId: 'kubeconfig-prod', variable: 'KUBECONFIG')]) {
sh '''
kubectl create secret generic api-secret \
--from-literal=db-password="$DB_PASSWORD" \
--dry-run=client -o yaml | kubectl apply -f -
'''
}

There’s something you absolutely need to know here. By default, a Kubernetes Secret isn’t encrypted; it’s just base64-encoded. Anyone who can run kubectl get secret -o yaml can decode it and see the plain text. So I take care of a few things alongside it.

  • Encryption at rest: On EKS, configure envelope encryption of Secrets with a KMS key so they aren’t stored in etcd as plain text.
  • Restrict reads with RBAC: Grant permission to get and list Secrets only to accounts that truly need it. Split namespaces by service or environment as well.
  • IAM permissions via roles, not keys: If a Pod needs to access AWS KMS or S3, don’t put access keys in a Secret; assign a role directly to the Pod with IRSA (IAM Roles for Service Accounts) or EKS Pod Identity. If you fetch the properties decryption key I’ll talk about below this way too, that’s one fewer secret.
  • To go further: Keep values in AWS Secrets Manager and sync them with a tool like External Secrets Operator, and you can manage the source of truth for secrets outside the cluster and automate rotation as well.

And since human mistakes can always happen, scanning at the commit stage with a tool like gitleaks to make sure no secrets slipped in serves as a final safety net.

Encrypting properties files

Even if you lock down the infrastructure well, it’s game over if the config file gets stolen. That’s because DB connection info, external API keys, and the JWT signing key are all in the properties file.

So I encrypt the entire properties file and decrypt it in the application’s main method at startup. The key needed for decryption is issued by KMS (Key Management Server). This way, even if someone pulls the config file off the server, without the key that file is just an unreadable lump of bytes.

sequenceDiagram participant App as Spring boot participant KMS App-->>KMS: Request properties encryption key (API Key) KMS-->>App: Return encryption key App->>App: Encrypt properties file App-->>KMS: Request properties file decryption key to start the application (API Key) KMS-->>App: Return decryption key App->>App: Decrypt properties file and start the application

There are still points worth noting with this structure.

  • The secret didn’t disappear; it moved to KMS. In the end, “who can get the key from KMS” becomes the new front door. Allow key issuance only through that server’s role (IAM Role, etc.), and block human accounts from pulling out the key. The point from part 1 that “the real front door is IAM” comes up again here.
  • Decrypted values exist in memory as plain text. If an endpoint like Spring Boot Actuator’s heapdump is open, the values you worked so hard to encrypt can leak out along with the memory. Expose only the Actuator endpoints you truly need, and block access from outside.
  • Be careful with logs too. If code that prints config values for debugging at startup is left in, that one line neutralizes the encryption.

Should I switch from AES to RSA?

I’m currently encrypting and decrypting with AES, and I’m thinking about switching to RSA asymmetric keys. When I laid it out, there were a few things to weigh.

The biggest advantage of RSA is that you can separate the side that encrypts from the side that decrypts. Hand out the public key to developers or the CI pipeline so anyone can encrypt config files, and give the private key only to production servers, and you can have people who “can encrypt but can’t decrypt.” With AES, the same key does both encryption and decryption, so this kind of separation is impossible.

But RSA can’t directly encrypt large data. With RSA-2048 and OAEP (SHA-256) padding, you can encrypt only about 190 bytes at a time, so encrypting an entire properties file with RSA isn’t realistic. So in practice, you end up mixing them like this.

  1. Encrypt the file with a freshly generated AES key each time.
  2. Encrypt that AES key with the RSA public key and store it alongside the file.
  3. The server unwraps the AES key with the RSA private key, then decrypts the file with that AES key.

This is what’s commonly called envelope encryption. In other words, the direction of my thinking is closer to “layer RSA on top of AES” than “switch from AES to RSA.” And once you introduce RSA, where to store the private key becomes the new problem, so I think the cleanest approach is to keep the private key inside KMS as well and never take it out. If you use AWS KMS, it provides this envelope encryption as a built-in feature, so I plan to compare it before implementing it myself.

Error pages that say nothing

Error pages deserve a closer look. Attackers like error pages more than normal pages. They deliberately feed in odd values to trigger errors, and from the stack traces printed in the response they pick up framework versions, package structure, class names, and even SQL statements and table names. The SQL injection I covered earlier usually starts with error messages like these as clues, too.

java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; ... near ''' at line 1
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(...)
at com.example.order.mapper.OrderMapper.findOrder(...)

If a response like this goes straight to the user’s screen, it tells them all at once what kind of DB you use, the fact that quotes go straight into your queries, and even your internal package paths.

There’s one principle. Tell users only what they should do, not what happened, and leave the detailed cause only in server logs. The same goes for 404, 403, and 500 alike.

In Spring Boot, catch every exception in one place with @RestControllerAdvice and turn it into a response in a fixed format. The key is not putting the exception message (e.getMessage()) into the response as is. Instead, if you send down a request ID along with it, you can look up the server log right away with that ID when a user contacts you.

public record ErrorResponse(String code, String message, String requestId) {}
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NoResourceFoundException e) {
return error(HttpStatus.NOT_FOUND, "NOT_FOUND", "The page you requested could not be found.");
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception e) {
log.error("Unhandled exception", e); // Stack traces go to the log only
return error(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "A temporary error occurred.");
}
private ResponseEntity<ErrorResponse> error(HttpStatus status, String code, String message) {
return ResponseEntity.status(status)
.body(new ErrorResponse(code, message, MDC.get("requestId")));
}
}

Watch out for 401 and 403. Authentication and authorization errors occur in the Spring Security filters, so they never reach @RestControllerAdvice. So you need to set up the Security config separately to respond in the same format.

http.exceptionHandling(e -> e
.authenticationEntryPoint((req, res, ex) ->
writeError(res, HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "You need to log in."))
.accessDeniedHandler((req, res, ex) ->
writeError(res, HttpStatus.FORBIDDEN, "FORBIDDEN", "You don't have permission to access this.")));

One more thing here. For data whose very existence you don’t want to reveal, like someone else’s order, returning 404 instead of 403 is also an option. A 403 means “that data exists, but you can’t see it,” so it tells an attacker poking around with different numbers which numbers actually exist.

In Next.js, build the error screens yourself with app/not-found.tsx and app/error.tsx. In production, Next.js replaces server component error messages with generic text and passes along only a digest value for tracing. On the error screen, too, don’t show error.message as is; show only fixed text and the digest.

app/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>A temporary error occurred.</h2>
<p>If the problem persists, please let us know error code {error.digest}.</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}

If Next.js calls the backend API on the browser’s behalf, it’s also important not to pass the backend’s error response body straight through to the browser in the Route Handler. If you pick out and forward only the code and message the backend has prepared, then even if exception handling is missing somewhere in the backend, nothing leaks all the way to the browser. It’s locking things twice.

Logging as a supporting measure

If everything I’ve talked about so far is a “blocking” measure, logging is a “noticing” measure. In part 1, I said there’s no perfect blocking, so noticing quickly when you’ve been breached is what matters. If CloudTrail and GuardDuty are infrastructure-level records, only the Spring Boot logs know what happened inside the application. So I use logging as a supporting security measure as well.

Logs can’t stop attacks. But patterns like hundreds of login failures from the same IP, or one user looking up other people’s order numbers in sequence and racking up authorization errors one after another, only show up in logs. When an incident happens, it’s also the logs that let you retrace “when, who, and what.”

What to log

From a security standpoint, these are the events I always log.

  • Login successes and failures, logouts
  • Requests denied for lack of permission (401, 403)
  • Sensitive actions like password changes, permission changes, and personal data downloads
  • Admin feature usage history

Spring Security publishes authentication successes and failures as events, so you can log them cleanly with a single listener.

@Slf4j
@Component
public class SecurityEventLogger {
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
log.info("[AUTH_SUCCESS] user={}", event.getAuthentication().getName());
}
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
log.warn("[AUTH_FAILURE] user={} reason={}",
event.getAuthentication().getName(),
event.getException().getClass().getSimpleName());
}
}

And if you put a unique ID for each request into the MDC (Mapped Diagnostic Context), you can group scattered log lines by request and trace them. When Next.js calls the backend, pass this ID along as a header, and everything from the frontend to the DB query connects in a single line.

@Component
public class RequestIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
.filter(id -> id.matches("[A-Za-z0-9-]{1,64}"))
.orElse(UUID.randomUUID().toString());
MDC.put("requestId", requestId);
try {
chain.doFilter(request, response);
} finally {
MDC.clear();
}
}
}

What not to log

Logs are a supporting measure, but used wrong, they become a leak path in themselves. Log files are seen by more people, and copied to more places, than you’d think.

  • Never log passwords, tokens, or Secret Keys. Make sure debug logs that print entire request bodies or headers aren’t left running in production. All the effort of encrypting properties and using Jenkins Credentials can collapse with one log line.
  • Mask personal information. Log values like phone numbers, email addresses, and resident registration numbers masked, like 010-****-1234.
  • Don’t log user input as is. There’s a log injection attack that mixes newline characters (CR/LF) into input to forge fake log lines. Strip newlines from user input or log it in a structured format like JSON. Filtering the header value with a regex in the RequestIdFilter above is for the same reason.

Get logs off the server

Logs that live only on the server can be deleted by an attacker if the server is breached. So send logs straight to external storage like CloudWatch Logs, and split permissions so the application server can’t delete or modify them. Since Spring Boot 3.4, the logging.structured.format.console setting alone gives you structured logs in JSON format, which makes searching and setting alarms after collection much easier. Set up a CloudWatch alarm that notifies you when login failures pile up in a short time, and logs finally become a “noticing” measure.

Keep logs long, and tamper-proof

Retention period matters too. On projects, some clients ask for logs to be kept for at least six months. There are legal standards as well. Under Article 8 of Korea’s “Standards for Measures to Ensure the Security of Personal Information,” access records for personal information processing systems must be kept for at least one year, and for at least two years if the system has 50,000 or more data subjects or processes unique identifying information or sensitive information.

But piling all your logs into CloudWatch Logs for years gets expensive. So I also drop logs into S3 as files for long-term storage.

  • Use CloudWatch Logs for short-term retention. It’s for searching recent logs and setting alarms, so keep the retention period short.
  • Export to S3 and keep them as files. Connect a CloudWatch Logs subscription filter to Kinesis Data Firehose, and logs pile up in S3 automatically. If you split paths by date, you can later query them like SQL with Athena.
  • Cut costs with lifecycle policies. Automatically move logs past a certain age to S3 Glacier-class storage. It’s the perfect store for logs you rarely need to pull out but absolutely must keep.
  • Lock them so nobody can modify them. With S3 Object Lock, during the retention period not even an admin account can delete or overwrite log files. Logs only mean something as audit evidence if, when an incident happens, you can say “these logs weren’t tampered with.”
  • The log bucket is a security target too. Logs gather information like user IDs, IPs, and request paths, so encrypt the bucket with a KMS key and restrict access to the auditor role only.

That said, “permanent retention” deserves a second thought. If personal information is mixed into the logs, holding on to it longer than necessary itself widens the damage if it leaks. So it’s safer to clearly agree on a retention period with the client and have logs deleted automatically by a lifecycle policy once that period passes.

Finally, a logging library is still a library. As with the Log4Shell incident a few years ago, the very code that writes logs can become a vulnerability, so don’t neglect updates just because it’s a supporting measure.

To sum up

If infrastructure is about reducing the doors that let people in, secure coding is about trusting nothing that comes through those doors.

  • SQL: in MyBatis, always #{}; where you have no choice, like sorting, use an allowlist
  • Tokens: in HttpOnly, Secure, SameSite cookies, not browser JS
  • Authorization: don’t stop at role checks; verify that the user owns the data
  • Input: even if validated on the frontend, validate again on the server
  • Secrets: not a single one in source code; Jenkins Credentials for build and deploy, Kubernetes Secrets at runtime
  • Config: encrypt the file, keep the key in KMS, and don’t let decrypted values leak through Actuator or logs
  • Logs: record every security event, but never secrets or personal information, and keep them long-term and tamper-proof in S3, off the server

And none of this is something you do once and are done with. New library vulnerabilities come out every day, and code that’s safe today may not be tomorrow. In the end, security isn’t design, it’s habit. No! It’s obsession.

Even if you manage it so obsessively that others ask “Are you paranoid?”, security still gets breached. All security eventually gets breached. But giving up because it’ll be breached anyway is foolish. Stretch out the time it takes to break in as much as possible, minimize the damage when it happens, and notice the breach faster than anyone else. That’s everything we can do, and what we absolutely must do.

That’s why I think pathological meticulousness, an obsession with security, is a basic quality every developer should have.

Bye!!

Farewell

[ Comments ]Shown after approval

Loading comments...